[ML] Migrate machine learning URLs to BrowserRouter format for APM, Security, and Infra (#78209)

* [ML] Adds ability to pass multiple jobIds to job management url

* [ML][APM] Update links to jobs management page for MLLink and LegacyJobsCallout

* [ML][APM] Update useTimeSeriesExplorerHref

* [ML][APM] Update tests

* [ML][APM] Move test from useTimeSeriesExplorerHref to MLJobLink.test.tsx

* [ML][Infra] Update ML links in infra to non-hash paths

* [ML] Move MlUrlGenerator registration outside of licensing block for security solution

* [ML][Security] Update ml links in security

* [ML][APM] Update test snapshots

* [ML][APM] Update snapshots

* [ML][Security solution] Update tests

* [ML] Update MLLink to include globalState

* [ML] Update useTimeSeriesExplorerHref

* [ML] Update apm and security_solution to use useMlHref hook

* [ML] Update APM to use useUrlParams hook, update security solution hook

* [ML] Update tests, fix duplicate imports

* [ML] Update imports, remove ml exports to shared cause it's not needed

[ML] Add import

* [ML] Update snapshot

* [ML] Fix warnings for jobs_table.test.tsx

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen 2020-09-30 18:53:17 -05:00 committed by GitHub
parent 06d1628a00
commit 2344dcfae8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 350 additions and 186 deletions

View file

@ -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,
},
},
},
},
}
}
>

View file

@ -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 (
<EuiCallOut
title={i18n.translate(
@ -28,11 +39,7 @@ export function LegacyJobsCallout() {
}
)}
</p>
<EuiButton
href={core.http.basePath.prepend(
'/app/ml#/jobs?mlManagement=(jobId:high_mean_response_time)'
)}
>
<EuiButton href={mlADLink}>
{i18n.translate(
'xpack.apm.settings.anomaly_detection.legacy_jobs.button',
{ defaultMessage: 'Review jobs' }

View file

@ -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(
() => (
<MLJobLink
jobId="apm-production-485b-high_mean_transaction_duration"
serviceName="opbeans-java"
transactionType="request"
/>
),
{
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'))"`
);
});
});

View file

@ -11,9 +11,7 @@ import { MLLink } from './MLLink';
test('MLLink produces the correct URL', async () => {
const href = await getRenderedHref(
() => (
<MLLink path="/some/path" query={{ ml: { jobIds: ['something'] } }} />
),
() => <MLLink query={{ ml: { jobIds: ['something'] } }} />,
{
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))"`
);
});

View file

@ -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 (
<EuiLink
children={children}
href={href}
href={mlADLink}
external={external}
target={external ? '_blank' : undefined}
/>

View file

@ -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)))"`
);
});
});

View file

@ -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;
}

View file

@ -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<string, unknown> = {
[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({

View file

@ -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;

View file

@ -24,6 +24,7 @@ import {
ESSearchRequest,
} from '../../typings/elasticsearch';
import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext';
import { UrlParamsProvider } from '../context/UrlParamsContext';
const originalConsoleWarn = console.warn; // eslint-disable-line no-console
/**
@ -67,7 +68,9 @@ export async function getRenderedHref(Component: React.FC, location: Location) {
const el = render(
<MockApmPluginContextWrapper>
<MemoryRouter initialEntries={[location]}>
<Component />
<UrlParamsProvider>
<Component />
</UrlParamsProvider>
</MemoryRouter>
</MockApmPluginContextWrapper>
);

View file

@ -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 },
};
};

View file

@ -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();
});

View file

@ -55,7 +55,7 @@ export type MlGenericUrlState = MLPageState<
>;
export interface AnomalyDetectionQueryState {
jobId?: JobId;
jobId?: JobId | string[];
groupIds?: string[];
globalState?: MlCommonGlobalState;
}

View file

@ -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) {

View file

@ -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;

View file

@ -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);

View file

@ -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

View file

@ -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';

View file

@ -106,7 +106,7 @@ export function registerUrlGenerator(
core: CoreSetup<MlStartDependencies>
) {
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'),

View file

@ -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;
};

View file

@ -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<MlStartDependencies, MlPluginStart>;
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
private appUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
private urlGenerator: undefined | UrlGeneratorContract<typeof ML_APP_URL_GENERATOR>;
constructor(private initializerContext: PluginInitializerContext) {}
@ -98,6 +105,10 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
},
});
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<MlPluginSetup, MlPluginStart> {
registerFeature,
registerManagementSection,
registerMlUiActions,
registerUrlGenerator,
MlCardState,
} = await import('./register_helper');
@ -118,11 +128,6 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
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<MlPluginSetup, MlPluginStart> {
}
});
return {};
return {
urlGenerator: this.urlGenerator,
};
}
start(core: CoreStart, deps: any) {
@ -159,7 +166,9 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
http: core.http,
i18n: core.i18n,
});
return {};
return {
urlGenerator: this.urlGenerator,
};
}
public stop() {}

View file

@ -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';

View file

@ -32,5 +32,5 @@
],
"server": true,
"ui": true,
"requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"]
"requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists", "ml"]
}

View file

@ -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(<Component />);
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(
<JobsTableComponent
isLoading={true}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
test('should render the hyperlink which points specifically to the job id', async () => {
const href = await getRenderedHref(
() => (
<JobsTableComponent
isLoading={true}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
),
'[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(
<JobsTableComponent
isLoading={true}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
const href = await getRenderedHref(
() => (
<JobsTableComponent
isLoading={true}
jobs={securityJobs}
onJobStateChange={onJobStateChangeMock}
/>
),
'[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(
<JobsTableComponent
isLoading={false}
@ -87,10 +104,12 @@ describe('JobsTableComponent', () => {
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(
<JobsTableComponent
isLoading={true}
@ -98,6 +117,8 @@ describe('JobsTableComponent', () => {
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);
});
});
});

View file

@ -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 (
<JobNameWrapper>
<EuiLink data-test-subj="jobs-table-link" href={jobUrl} target="_blank">
<EuiText size="s">{id}</EuiText>
</EuiLink>
<EuiText color="subdued" size="xs">
{description.length > truncateThreshold
? `${description.substring(0, truncateThreshold)}...`
: description}
</EuiText>
</JobNameWrapper>
);
};
const getJobsTableColumns = (
isLoading: boolean,
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>,
@ -44,20 +76,7 @@ const getJobsTableColumns = (
{
name: i18n.COLUMN_JOB_NAME,
render: ({ id, description }: SecurityJob) => (
<JobNameWrapper>
<EuiLink
data-test-subj="jobs-table-link"
href={`${basePath}/app/ml#/jobs?mlManagement=(jobId:${encodeURI(id)})`}
target="_blank"
>
<EuiText size="s">{id}</EuiText>
</EuiLink>
<EuiText color="subdued" size="xs">
{description.length > truncateThreshold
? `${description.substring(0, truncateThreshold)}...`
: description}
</EuiText>
</JobNameWrapper>
<JobName id={id} description={description} basePath={basePath} />
),
},
{
@ -141,22 +160,32 @@ export const JobsTable = React.memo(JobsTableComponent);
JobsTable.displayName = 'JobsTable';
export const NoItemsMessage = React.memo(({ basePath }: { basePath: string }) => (
<EuiEmptyPrompt
title={<h3>{i18n.NO_ITEMS_TEXT}</h3>}
titleSize="xs"
actions={
<EuiButton
href={`${basePath}/app/ml#/jobs/new_job/step/index_or_search`}
iconType="popout"
iconSide="right"
size="s"
target="_blank"
>
{i18n.CREATE_CUSTOM_JOB}
</EuiButton>
}
/>
));
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 (
<EuiEmptyPrompt
title={<h3>{i18n.NO_ITEMS_TEXT}</h3>}
titleSize="xs"
actions={
<EuiButton
href={createNewAnomalyDetectionJoUrl}
iconType="popout"
iconSide="right"
size="s"
target="_blank"
>
{i18n.CREATE_CUSTOM_JOB}
</EuiButton>
}
/>
);
});
NoItemsMessage.displayName = 'NoItemsMessage';

View file

@ -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<string, unknown> = {
[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;
};

View file

@ -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 = <span data-test-subj="machineLearningJobId">{jobId}</span>;

View file

@ -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 &