[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:
parent
06d1628a00
commit
2344dcfae8
|
@ -34,6 +34,7 @@ exports[`Home component should render services 1`] = `
|
||||||
},
|
},
|
||||||
"http": Object {
|
"http": Object {
|
||||||
"basePath": Object {
|
"basePath": Object {
|
||||||
|
"get": [Function],
|
||||||
"prepend": [Function],
|
"prepend": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -51,7 +52,18 @@ exports[`Home component should render services 1`] = `
|
||||||
"get$": [Function],
|
"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 {
|
"http": Object {
|
||||||
"basePath": Object {
|
"basePath": Object {
|
||||||
|
"get": [Function],
|
||||||
"prepend": [Function],
|
"prepend": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -112,7 +125,18 @@ exports[`Home component should render traces 1`] = `
|
||||||
"get$": [Function],
|
"get$": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"plugins": Object {},
|
"plugins": Object {
|
||||||
|
"ml": Object {
|
||||||
|
"urlGenerator": MlUrlGenerator {
|
||||||
|
"createUrl": [Function],
|
||||||
|
"id": "ML_APP_URL_GENERATOR",
|
||||||
|
"params": Object {
|
||||||
|
"appBasePath": "/app/ml",
|
||||||
|
"useHash": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -8,9 +8,20 @@ import { EuiCallOut, EuiButton } from '@elastic/eui';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
|
import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
|
||||||
|
import { useMlHref } from '../../../../../../ml/public';
|
||||||
|
|
||||||
export function LegacyJobsCallout() {
|
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 (
|
return (
|
||||||
<EuiCallOut
|
<EuiCallOut
|
||||||
title={i18n.translate(
|
title={i18n.translate(
|
||||||
|
@ -28,11 +39,7 @@ export function LegacyJobsCallout() {
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<EuiButton
|
<EuiButton href={mlADLink}>
|
||||||
href={core.http.basePath.prepend(
|
|
||||||
'/app/ml#/jobs?mlManagement=(jobId:high_mean_response_time)'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{i18n.translate(
|
{i18n.translate(
|
||||||
'xpack.apm.settings.anomaly_detection.legacy_jobs.button',
|
'xpack.apm.settings.anomaly_detection.legacy_jobs.button',
|
||||||
{ defaultMessage: 'Review jobs' }
|
{ defaultMessage: 'Review jobs' }
|
||||||
|
|
|
@ -22,7 +22,7 @@ describe('MLJobLink', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(href).toMatchInlineSnapshot(
|
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 () => {
|
it('should produce the correct URL with jobId, serviceName, and transactionType', async () => {
|
||||||
|
@ -41,7 +41,27 @@ describe('MLJobLink', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(href).toMatchInlineSnapshot(
|
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'))"`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,9 +11,7 @@ import { MLLink } from './MLLink';
|
||||||
|
|
||||||
test('MLLink produces the correct URL', async () => {
|
test('MLLink produces the correct URL', async () => {
|
||||||
const href = await getRenderedHref(
|
const href = await getRenderedHref(
|
||||||
() => (
|
() => <MLLink query={{ ml: { jobIds: ['something'] } }} />,
|
||||||
<MLLink path="/some/path" query={{ ml: { jobIds: ['something'] } }} />
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
search:
|
search:
|
||||||
'?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
|
'?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0',
|
||||||
|
@ -21,6 +19,6 @@ test('MLLink produces the correct URL', async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(href).toMatchInlineSnapshot(
|
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))"`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,11 +6,9 @@
|
||||||
|
|
||||||
import { EuiLink } from '@elastic/eui';
|
import { EuiLink } from '@elastic/eui';
|
||||||
import React from 'react';
|
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 { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
|
||||||
import { getTimepickerRisonData, TimepickerRisonData } from '../rison_helpers';
|
import { useMlHref, ML_PAGES } from '../../../../../../ml/public';
|
||||||
|
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||||
|
|
||||||
interface MlRisonData {
|
interface MlRisonData {
|
||||||
ml?: {
|
ml?: {
|
||||||
|
@ -26,28 +24,41 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MLLink({ children, path = '', query = {}, external }: Props) {
|
export function MLLink({ children, path = '', query = {}, external }: Props) {
|
||||||
const { core } = useApmPluginContext();
|
const {
|
||||||
const location = useLocation();
|
core,
|
||||||
|
plugins: { ml },
|
||||||
|
} = useApmPluginContext();
|
||||||
|
|
||||||
const risonQuery: MlRisonData & TimepickerRisonData = getTimepickerRisonData(
|
let jobIds: string[] = [];
|
||||||
location.search
|
if (query.ml?.jobIds) {
|
||||||
);
|
jobIds = query.ml.jobIds;
|
||||||
|
|
||||||
if (query.ml) {
|
|
||||||
risonQuery.ml = query.ml;
|
|
||||||
}
|
}
|
||||||
|
const { urlParams } = useUrlParams();
|
||||||
|
const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = urlParams;
|
||||||
|
|
||||||
const href = url.format({
|
// default to link to ML Anomaly Detection jobs management page
|
||||||
pathname: core.http.basePath.prepend('/app/ml'),
|
const mlADLink = useMlHref(ml, core.http.basePath.get(), {
|
||||||
hash: `${path}?_g=${rison.encode(
|
page: ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE,
|
||||||
risonQuery as RisonValue
|
pageState: {
|
||||||
)}&mlManagement=${rison.encode({ groupIds: ['apm'] })}`,
|
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 (
|
return (
|
||||||
<EuiLink
|
<EuiLink
|
||||||
children={children}
|
children={children}
|
||||||
href={href}
|
href={mlADLink}
|
||||||
external={external}
|
external={external}
|
||||||
target={external ? '_blank' : undefined}
|
target={external ? '_blank' : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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)))"`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -4,12 +4,9 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* 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 { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
|
||||||
import { getTimepickerRisonData } from '../rison_helpers';
|
import { useMlHref } from '../../../../../../ml/public';
|
||||||
|
import { useUrlParams } from '../../../../hooks/useUrlParams';
|
||||||
|
|
||||||
export function useTimeSeriesExplorerHref({
|
export function useTimeSeriesExplorerHref({
|
||||||
jobId,
|
jobId,
|
||||||
|
@ -20,41 +17,38 @@ export function useTimeSeriesExplorerHref({
|
||||||
serviceName?: string;
|
serviceName?: string;
|
||||||
transactionType?: string;
|
transactionType?: string;
|
||||||
}) {
|
}) {
|
||||||
const { core } = useApmPluginContext();
|
// default to link to ML Anomaly Detection jobs management page
|
||||||
const location = useLocation();
|
const {
|
||||||
const { time, refreshInterval } = getTimepickerRisonData(location.search);
|
core,
|
||||||
|
plugins: { ml },
|
||||||
|
} = useApmPluginContext();
|
||||||
|
const { urlParams } = useUrlParams();
|
||||||
|
const { rangeFrom, rangeTo, refreshInterval, refreshPaused } = urlParams;
|
||||||
|
|
||||||
const search = querystring.stringify(
|
const timeRange =
|
||||||
{
|
rangeFrom !== undefined && rangeTo !== undefined
|
||||||
_g: rison.encode({
|
? { from: rangeFrom, to: rangeTo }
|
||||||
ml: { jobIds: [jobId] },
|
: undefined;
|
||||||
time,
|
const mlAnomalyDetectionHref = useMlHref(ml, core.http.basePath.get(), {
|
||||||
refreshInterval,
|
page: 'timeseriesexplorer',
|
||||||
}),
|
pageState: {
|
||||||
|
jobIds: [jobId],
|
||||||
|
timeRange,
|
||||||
|
refreshInterval:
|
||||||
|
refreshPaused !== undefined && refreshInterval !== undefined
|
||||||
|
? { pause: refreshPaused, value: refreshInterval }
|
||||||
|
: undefined,
|
||||||
|
zoom: timeRange,
|
||||||
...(serviceName && transactionType
|
...(serviceName && transactionType
|
||||||
? {
|
? {
|
||||||
_a: rison.encode({
|
entities: {
|
||||||
mlTimeSeriesExplorer: {
|
'service.name': serviceName,
|
||||||
entities: {
|
'transaction.type': transactionType,
|
||||||
'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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { ApmPluginContext, ApmPluginContextValue } from '.';
|
||||||
import { ConfigSchema } from '../..';
|
import { ConfigSchema } from '../..';
|
||||||
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
|
import { UI_SETTINGS } from '../../../../../../src/plugins/data/common';
|
||||||
import { createCallApmApi } from '../../services/rest/createCallApmApi';
|
import { createCallApmApi } from '../../services/rest/createCallApmApi';
|
||||||
|
import { MlUrlGenerator } from '../../../../ml/public';
|
||||||
|
|
||||||
const uiSettings: Record<string, unknown> = {
|
const uiSettings: Record<string, unknown> = {
|
||||||
[UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [
|
[UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [
|
||||||
|
@ -54,6 +55,7 @@ const mockCore = {
|
||||||
http: {
|
http: {
|
||||||
basePath: {
|
basePath: {
|
||||||
prepend: (path: string) => `/basepath${path}`,
|
prepend: (path: string) => `/basepath${path}`,
|
||||||
|
get: () => `/basepath`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
i18n: {
|
i18n: {
|
||||||
|
@ -78,10 +80,18 @@ const mockConfig: ConfigSchema = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockPlugin = {
|
||||||
|
ml: {
|
||||||
|
urlGenerator: new MlUrlGenerator({
|
||||||
|
appBasePath: '/app/ml',
|
||||||
|
useHash: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
export const mockApmPluginContextValue = {
|
export const mockApmPluginContextValue = {
|
||||||
config: mockConfig,
|
config: mockConfig,
|
||||||
core: mockCore,
|
core: mockCore,
|
||||||
plugins: {},
|
plugins: mockPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MockApmPluginContextWrapper({
|
export function MockApmPluginContextWrapper({
|
||||||
|
|
|
@ -36,12 +36,14 @@ import { featureCatalogueEntry } from './featureCatalogueEntry';
|
||||||
import { toggleAppLinkInNav } from './toggleAppLinkInNav';
|
import { toggleAppLinkInNav } from './toggleAppLinkInNav';
|
||||||
import { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
|
import { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
|
||||||
import { registerApmAlerts } from './components/alerting/register_apm_alerts';
|
import { registerApmAlerts } from './components/alerting/register_apm_alerts';
|
||||||
|
import { MlPluginSetup, MlPluginStart } from '../../ml/public';
|
||||||
|
|
||||||
export type ApmPluginSetup = void;
|
export type ApmPluginSetup = void;
|
||||||
export type ApmPluginStart = void;
|
export type ApmPluginStart = void;
|
||||||
|
|
||||||
export interface ApmPluginSetupDeps {
|
export interface ApmPluginSetupDeps {
|
||||||
alerts?: AlertingPluginPublicSetup;
|
alerts?: AlertingPluginPublicSetup;
|
||||||
|
ml?: MlPluginSetup;
|
||||||
data: DataPublicPluginSetup;
|
data: DataPublicPluginSetup;
|
||||||
features: FeaturesPluginSetup;
|
features: FeaturesPluginSetup;
|
||||||
home?: HomePublicPluginSetup;
|
home?: HomePublicPluginSetup;
|
||||||
|
@ -52,6 +54,7 @@ export interface ApmPluginSetupDeps {
|
||||||
|
|
||||||
export interface ApmPluginStartDeps {
|
export interface ApmPluginStartDeps {
|
||||||
alerts?: AlertingPluginPublicStart;
|
alerts?: AlertingPluginPublicStart;
|
||||||
|
ml?: MlPluginStart;
|
||||||
data: DataPublicPluginStart;
|
data: DataPublicPluginStart;
|
||||||
home: void;
|
home: void;
|
||||||
licensing: void;
|
licensing: void;
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {
|
||||||
ESSearchRequest,
|
ESSearchRequest,
|
||||||
} from '../../typings/elasticsearch';
|
} from '../../typings/elasticsearch';
|
||||||
import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext';
|
import { MockApmPluginContextWrapper } from '../context/ApmPluginContext/MockApmPluginContext';
|
||||||
|
import { UrlParamsProvider } from '../context/UrlParamsContext';
|
||||||
|
|
||||||
const originalConsoleWarn = console.warn; // eslint-disable-line no-console
|
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(
|
const el = render(
|
||||||
<MockApmPluginContextWrapper>
|
<MockApmPluginContextWrapper>
|
||||||
<MemoryRouter initialEntries={[location]}>
|
<MemoryRouter initialEntries={[location]}>
|
||||||
<Component />
|
<UrlParamsProvider>
|
||||||
|
<Component />
|
||||||
|
</UrlParamsProvider>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
</MockApmPluginContextWrapper>
|
</MockApmPluginContextWrapper>
|
||||||
);
|
);
|
||||||
|
|
|
@ -58,7 +58,7 @@ export const getOverallAnomalyExplorerLinkDescriptor = (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app: 'ml',
|
app: 'ml',
|
||||||
hash: '/explorer',
|
pathname: '/explorer',
|
||||||
search: { _g },
|
search: { _g },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -89,7 +89,7 @@ export const getEntitySpecificSingleMetricViewerLink = (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
app: 'ml',
|
app: 'ml',
|
||||||
hash: '/timeseriesexplorer',
|
pathname: '/timeseriesexplorer',
|
||||||
search: { _g, _a },
|
search: { _g, _a },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -129,7 +129,7 @@ describe('useLinkProps hook', () => {
|
||||||
it('Provides the correct props with hash options', () => {
|
it('Provides the correct props with hash options', () => {
|
||||||
const { result } = renderUseLinkPropsHook({
|
const { result } = renderUseLinkPropsHook({
|
||||||
app: 'ml',
|
app: 'ml',
|
||||||
hash: '/explorer',
|
pathname: '/explorer',
|
||||||
search: {
|
search: {
|
||||||
type: 'host',
|
type: 'host',
|
||||||
id: 'some-id',
|
id: 'some-id',
|
||||||
|
@ -137,7 +137,7 @@ describe('useLinkProps hook', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.current.href).toBe(
|
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();
|
expect(result.current.onClick).toBeDefined();
|
||||||
});
|
});
|
||||||
|
@ -145,7 +145,7 @@ describe('useLinkProps hook', () => {
|
||||||
it('Provides the correct props with more complex encoding', () => {
|
it('Provides the correct props with more complex encoding', () => {
|
||||||
const { result } = renderUseLinkPropsHook({
|
const { result } = renderUseLinkPropsHook({
|
||||||
app: 'ml',
|
app: 'ml',
|
||||||
hash: '/explorer',
|
pathname: '/explorer',
|
||||||
search: {
|
search: {
|
||||||
type: 'host + host',
|
type: 'host + host',
|
||||||
name: 'this name has spaces and ** and %',
|
name: 'this name has spaces and ** and %',
|
||||||
|
@ -155,7 +155,7 @@ describe('useLinkProps hook', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.current.href).toBe(
|
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();
|
expect(result.current.onClick).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
|
@ -55,7 +55,7 @@ export type MlGenericUrlState = MLPageState<
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface AnomalyDetectionQueryState {
|
export interface AnomalyDetectionQueryState {
|
||||||
jobId?: JobId;
|
jobId?: JobId | string[];
|
||||||
groupIds?: string[];
|
groupIds?: string[];
|
||||||
globalState?: MlCommonGlobalState;
|
globalState?: MlCommonGlobalState;
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,12 @@ import React, { Component, Fragment } from 'react';
|
||||||
|
|
||||||
import { ml } from '../../../../services/ml_api_service';
|
import { ml } from '../../../../services/ml_api_service';
|
||||||
import { JobGroup } from '../job_group';
|
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 { EuiSearchBar, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
|
@ -60,7 +65,7 @@ export class JobFilterBar extends Component {
|
||||||
if (groupIds !== undefined) {
|
if (groupIds !== undefined) {
|
||||||
defaultQueryText = getGroupQueryText(groupIds);
|
defaultQueryText = getGroupQueryText(groupIds);
|
||||||
} else if (jobId !== undefined) {
|
} else if (jobId !== undefined) {
|
||||||
defaultQueryText = jobId;
|
defaultQueryText = getJobQueryText(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaultQueryText !== undefined) {
|
if (defaultQueryText !== undefined) {
|
||||||
|
|
|
@ -6,4 +6,5 @@
|
||||||
|
|
||||||
export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string };
|
export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string };
|
||||||
export function getGroupQueryText(arr: string[]): string;
|
export function getGroupQueryText(arr: string[]): string;
|
||||||
|
export function getJobQueryText(arr: string | string[]): string;
|
||||||
export function clearSelectedJobIdFromUrl(str: string): void;
|
export function clearSelectedJobIdFromUrl(str: string): void;
|
||||||
|
|
|
@ -309,8 +309,13 @@ export function filterJobs(jobs, clauses) {
|
||||||
} else {
|
} else {
|
||||||
// filter other clauses, i.e. the toggle group buttons
|
// filter other clauses, i.e. the toggle group buttons
|
||||||
if (Array.isArray(c.value)) {
|
if (Array.isArray(c.value)) {
|
||||||
// the groups value is an array of group ids
|
// if it's an array of job ids
|
||||||
js = jobs.filter((job) => jobProperty(job, c.field).some((g) => c.value.indexOf(g) >= 0));
|
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 {
|
} else {
|
||||||
js = jobs.filter((job) => jobProperty(job, c.field) === c.value);
|
js = jobs.filter((job) => jobProperty(job, c.field) === c.value);
|
||||||
}
|
}
|
||||||
|
@ -353,6 +358,7 @@ function jobProperty(job, prop) {
|
||||||
job_state: 'jobState',
|
job_state: 'jobState',
|
||||||
datafeed_state: 'datafeedState',
|
datafeed_state: 'datafeedState',
|
||||||
groups: 'groups',
|
groups: 'groups',
|
||||||
|
id: 'id',
|
||||||
};
|
};
|
||||||
return job[propMap[prop]];
|
return job[propMap[prop]];
|
||||||
}
|
}
|
||||||
|
@ -389,6 +395,10 @@ export function getGroupQueryText(groupIds) {
|
||||||
return `groups:(${groupIds.join(' or ')})`;
|
return `groups:(${groupIds.join(' or ')})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getJobQueryText(jobIds) {
|
||||||
|
return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds;
|
||||||
|
}
|
||||||
|
|
||||||
export function clearSelectedJobIdFromUrl(url) {
|
export function clearSelectedJobIdFromUrl(url) {
|
||||||
if (typeof url === 'string') {
|
if (typeof url === 'string') {
|
||||||
url = decodeURIComponent(url);
|
url = decodeURIComponent(url);
|
||||||
|
|
|
@ -41,6 +41,7 @@ export type {
|
||||||
// Static exports
|
// Static exports
|
||||||
export { getSeverityColor, getSeverityType } from '../common/util/anomaly_utils';
|
export { getSeverityColor, getSeverityType } from '../common/util/anomaly_utils';
|
||||||
export { ANOMALY_SEVERITY } from '../common';
|
export { ANOMALY_SEVERITY } from '../common';
|
||||||
|
export { useMlHref, ML_PAGES, MlUrlGenerator } from './ml_url_generator';
|
||||||
|
|
||||||
// Bundled shared exports
|
// Bundled shared exports
|
||||||
// Exported this way so the code doesn't end up in ML's page load bundle
|
// Exported this way so the code doesn't end up in ML's page load bundle
|
||||||
|
|
|
@ -4,3 +4,5 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
export { MlUrlGenerator, registerUrlGenerator } from './ml_url_generator';
|
export { MlUrlGenerator, registerUrlGenerator } from './ml_url_generator';
|
||||||
|
export { useMlHref } from './use_ml_href';
|
||||||
|
export { ML_PAGES } from '../../common/constants/ml_url_generator';
|
||||||
|
|
|
@ -106,7 +106,7 @@ export function registerUrlGenerator(
|
||||||
core: CoreSetup<MlStartDependencies>
|
core: CoreSetup<MlStartDependencies>
|
||||||
) {
|
) {
|
||||||
const baseUrl = core.http.basePath.prepend('/app/ml');
|
const baseUrl = core.http.basePath.prepend('/app/ml');
|
||||||
share.urlGenerators.registerUrlGenerator(
|
return share.urlGenerators.registerUrlGenerator(
|
||||||
new MlUrlGenerator({
|
new MlUrlGenerator({
|
||||||
appBasePath: baseUrl,
|
appBasePath: baseUrl,
|
||||||
useHash: core.uiSettings.get('state:storeInSessionStorage'),
|
useHash: core.uiSettings.get('state:storeInSessionStorage'),
|
||||||
|
|
34
x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts
Normal file
34
x-pack/plugins/ml/public/ml_url_generator/use_ml_href.ts
Normal 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;
|
||||||
|
};
|
|
@ -16,7 +16,11 @@ import { BehaviorSubject } from 'rxjs';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
|
|
||||||
import type { ManagementSetup } from 'src/plugins/management/public';
|
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 { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||||
import type { DataPublicPluginStart } from 'src/plugins/data/public';
|
import type { DataPublicPluginStart } from 'src/plugins/data/public';
|
||||||
import type { HomePublicPluginSetup } from 'src/plugins/home/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 { PLUGIN_ICON_SOLUTION, PLUGIN_ID } from '../common/constants/app';
|
||||||
|
|
||||||
import { setDependencyCache } from './application/util/dependency_cache';
|
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 {
|
export interface MlStartDependencies {
|
||||||
data: DataPublicPluginStart;
|
data: DataPublicPluginStart;
|
||||||
|
@ -59,6 +65,7 @@ export type MlCoreSetup = CoreSetup<MlStartDependencies, MlPluginStart>;
|
||||||
|
|
||||||
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
private appUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
|
private appUpdater = new BehaviorSubject<AppUpdater>(() => ({}));
|
||||||
|
private urlGenerator: undefined | UrlGeneratorContract<typeof ML_APP_URL_GENERATOR>;
|
||||||
|
|
||||||
constructor(private initializerContext: PluginInitializerContext) {}
|
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));
|
const licensing = pluginsSetup.licensing.license$.pipe(take(1));
|
||||||
licensing.subscribe(async (license) => {
|
licensing.subscribe(async (license) => {
|
||||||
const [coreStart] = await core.getStartServices();
|
const [coreStart] = await core.getStartServices();
|
||||||
|
@ -109,7 +120,6 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
registerFeature,
|
registerFeature,
|
||||||
registerManagementSection,
|
registerManagementSection,
|
||||||
registerMlUiActions,
|
registerMlUiActions,
|
||||||
registerUrlGenerator,
|
|
||||||
MlCardState,
|
MlCardState,
|
||||||
} = await import('./register_helper');
|
} = await import('./register_helper');
|
||||||
|
|
||||||
|
@ -118,11 +128,6 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
if (pluginsSetup.home) {
|
if (pluginsSetup.home) {
|
||||||
registerFeature(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;
|
const { capabilities } = coreStart.application;
|
||||||
|
|
||||||
// register ML for the index pattern management no data screen.
|
// 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) {
|
start(core: CoreStart, deps: any) {
|
||||||
|
@ -159,7 +166,9 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> {
|
||||||
http: core.http,
|
http: core.http,
|
||||||
i18n: core.i18n,
|
i18n: core.i18n,
|
||||||
});
|
});
|
||||||
return {};
|
return {
|
||||||
|
urlGenerator: this.urlGenerator,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop() {}
|
public stop() {}
|
||||||
|
|
|
@ -12,4 +12,3 @@ export { registerEmbeddables } from './embeddables';
|
||||||
export { registerFeature } from './register_feature';
|
export { registerFeature } from './register_feature';
|
||||||
export { registerManagementSection } from './application/management';
|
export { registerManagementSection } from './application/management';
|
||||||
export { registerMlUiActions } from './ui_actions';
|
export { registerMlUiActions } from './ui_actions';
|
||||||
export { registerUrlGenerator } from './ml_url_generator';
|
|
||||||
|
|
|
@ -32,5 +32,5 @@
|
||||||
],
|
],
|
||||||
"server": true,
|
"server": true,
|
||||||
"ui": true,
|
"ui": true,
|
||||||
"requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"]
|
"requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists", "ml"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { shallow, mount } from 'enzyme';
|
import { shallow, mount } from 'enzyme';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { JobsTableComponent } from './jobs_table';
|
import { JobsTableComponent } from './jobs_table';
|
||||||
import { mockSecurityJobs } from '../api.mock';
|
import { mockSecurityJobs } from '../api.mock';
|
||||||
import { cloneDeep } from 'lodash/fp';
|
import { cloneDeep } from 'lodash/fp';
|
||||||
|
@ -14,9 +14,19 @@ import { SecurityJob } from '../types';
|
||||||
|
|
||||||
jest.mock('../../../lib/kibana');
|
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', () => {
|
describe('JobsTableComponent', () => {
|
||||||
let securityJobs: SecurityJob[];
|
let securityJobs: SecurityJob[];
|
||||||
let onJobStateChangeMock = jest.fn();
|
let onJobStateChangeMock = jest.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
securityJobs = cloneDeep(mockSecurityJobs);
|
securityJobs = cloneDeep(mockSecurityJobs);
|
||||||
onJobStateChangeMock = jest.fn();
|
onJobStateChangeMock = jest.fn();
|
||||||
|
@ -33,30 +43,36 @@ describe('JobsTableComponent', () => {
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render the hyperlink which points specifically to the job id', () => {
|
test('should render the hyperlink which points specifically to the job id', async () => {
|
||||||
const wrapper = mount(
|
const href = await getRenderedHref(
|
||||||
<JobsTableComponent
|
() => (
|
||||||
isLoading={true}
|
<JobsTableComponent
|
||||||
jobs={securityJobs}
|
isLoading={true}
|
||||||
onJobStateChange={onJobStateChangeMock}
|
jobs={securityJobs}
|
||||||
/>
|
onJobStateChange={onJobStateChangeMock}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
'[data-test-subj="jobs-table-link"]'
|
||||||
);
|
);
|
||||||
expect(wrapper.find('[data-test-subj="jobs-table-link"]').first().props().href).toEqual(
|
await waitFor(() =>
|
||||||
'/test/base/path/app/ml#/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)'
|
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';
|
securityJobs[0].id = 'job id with spaces';
|
||||||
const wrapper = mount(
|
const href = await getRenderedHref(
|
||||||
<JobsTableComponent
|
() => (
|
||||||
isLoading={true}
|
<JobsTableComponent
|
||||||
jobs={securityJobs}
|
isLoading={true}
|
||||||
onJobStateChange={onJobStateChangeMock}
|
jobs={securityJobs}
|
||||||
/>
|
onJobStateChange={onJobStateChangeMock}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
'[data-test-subj="jobs-table-link"]'
|
||||||
);
|
);
|
||||||
expect(wrapper.find('[data-test-subj="jobs-table-link"]').first().props().href).toEqual(
|
await waitFor(() =>
|
||||||
'/test/base/path/app/ml#/jobs?mlManagement=(jobId:job%20id%20with%20spaces)'
|
expect(href).toEqual("/app/ml/jobs?mlManagement=(jobId:'job%20id%20with%20spaces')")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -68,6 +84,7 @@ describe('JobsTableComponent', () => {
|
||||||
onJobStateChange={onJobStateChangeMock}
|
onJobStateChange={onJobStateChangeMock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
wrapper
|
wrapper
|
||||||
.find('button[data-test-subj="job-switch"]')
|
.find('button[data-test-subj="job-switch"]')
|
||||||
.first()
|
.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(
|
const wrapper = mount(
|
||||||
<JobsTableComponent
|
<JobsTableComponent
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
|
@ -87,10 +104,12 @@ describe('JobsTableComponent', () => {
|
||||||
onJobStateChange={onJobStateChangeMock}
|
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(
|
const wrapper = mount(
|
||||||
<JobsTableComponent
|
<JobsTableComponent
|
||||||
isLoading={true}
|
isLoading={true}
|
||||||
|
@ -98,6 +117,8 @@ describe('JobsTableComponent', () => {
|
||||||
onJobStateChange={onJobStateChangeMock}
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -22,10 +22,11 @@ import {
|
||||||
} from '@elastic/eui';
|
} from '@elastic/eui';
|
||||||
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { useBasePath } from '../../../lib/kibana';
|
import { useBasePath, useKibana } from '../../../lib/kibana';
|
||||||
import * as i18n from './translations';
|
import * as i18n from './translations';
|
||||||
import { JobSwitch } from './job_switch';
|
import { JobSwitch } from './job_switch';
|
||||||
import { SecurityJob } from '../types';
|
import { SecurityJob } from '../types';
|
||||||
|
import { useMlHref, ML_PAGES } from '../../../../../../ml/public';
|
||||||
|
|
||||||
const JobNameWrapper = styled.div`
|
const JobNameWrapper = styled.div`
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
|
@ -36,6 +37,37 @@ JobNameWrapper.displayName = 'JobNameWrapper';
|
||||||
// TODO: Use SASS mixin @include EuiTextTruncate when we switch from styled components
|
// TODO: Use SASS mixin @include EuiTextTruncate when we switch from styled components
|
||||||
const truncateThreshold = 200;
|
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 = (
|
const getJobsTableColumns = (
|
||||||
isLoading: boolean,
|
isLoading: boolean,
|
||||||
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>,
|
onJobStateChange: (job: SecurityJob, latestTimestampMs: number, enable: boolean) => Promise<void>,
|
||||||
|
@ -44,20 +76,7 @@ const getJobsTableColumns = (
|
||||||
{
|
{
|
||||||
name: i18n.COLUMN_JOB_NAME,
|
name: i18n.COLUMN_JOB_NAME,
|
||||||
render: ({ id, description }: SecurityJob) => (
|
render: ({ id, description }: SecurityJob) => (
|
||||||
<JobNameWrapper>
|
<JobName id={id} description={description} basePath={basePath} />
|
||||||
<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>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -141,22 +160,32 @@ export const JobsTable = React.memo(JobsTableComponent);
|
||||||
|
|
||||||
JobsTable.displayName = 'JobsTable';
|
JobsTable.displayName = 'JobsTable';
|
||||||
|
|
||||||
export const NoItemsMessage = React.memo(({ basePath }: { basePath: string }) => (
|
export const NoItemsMessage = React.memo(({ basePath }: { basePath: string }) => {
|
||||||
<EuiEmptyPrompt
|
const {
|
||||||
title={<h3>{i18n.NO_ITEMS_TEXT}</h3>}
|
services: { ml },
|
||||||
titleSize="xs"
|
} = useKibana();
|
||||||
actions={
|
|
||||||
<EuiButton
|
const createNewAnomalyDetectionJoUrl = useMlHref(ml, basePath, {
|
||||||
href={`${basePath}/app/ml#/jobs/new_job/step/index_or_search`}
|
page: ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_SELECT_INDEX,
|
||||||
iconType="popout"
|
});
|
||||||
iconSide="right"
|
|
||||||
size="s"
|
return (
|
||||||
target="_blank"
|
<EuiEmptyPrompt
|
||||||
>
|
title={<h3>{i18n.NO_ITEMS_TEXT}</h3>}
|
||||||
{i18n.CREATE_CUSTOM_JOB}
|
titleSize="xs"
|
||||||
</EuiButton>
|
actions={
|
||||||
}
|
<EuiButton
|
||||||
/>
|
href={createNewAnomalyDetectionJoUrl}
|
||||||
));
|
iconType="popout"
|
||||||
|
iconSide="right"
|
||||||
|
size="s"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{i18n.CREATE_CUSTOM_JOB}
|
||||||
|
</EuiButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
NoItemsMessage.displayName = 'NoItemsMessage';
|
NoItemsMessage.displayName = 'NoItemsMessage';
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from '../../../../common/constants';
|
} from '../../../../common/constants';
|
||||||
import { StartServices } from '../../../types';
|
import { StartServices } from '../../../types';
|
||||||
import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage';
|
import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage';
|
||||||
|
import { MlUrlGenerator } from '../../../../../ml/public';
|
||||||
|
|
||||||
const mockUiSettings: Record<string, unknown> = {
|
const mockUiSettings: Record<string, unknown> = {
|
||||||
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
|
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
|
||||||
|
@ -113,6 +114,12 @@ export const createStartServicesMock = (): StartServices => {
|
||||||
},
|
},
|
||||||
security,
|
security,
|
||||||
storage,
|
storage,
|
||||||
|
ml: {
|
||||||
|
urlGenerator: new MlUrlGenerator({
|
||||||
|
appBasePath: '/app/ml',
|
||||||
|
useHash: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
} as unknown) as StartServices;
|
} as unknown) as StartServices;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
|
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 { isJobStarted } from '../../../../../common/machine_learning/helpers';
|
||||||
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
|
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
|
||||||
import { useKibana } from '../../../../common/lib/kibana';
|
import { useKibana } from '../../../../common/lib/kibana';
|
||||||
|
@ -72,9 +72,16 @@ const Wrapper = styled.div`
|
||||||
|
|
||||||
const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
|
const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => {
|
||||||
const { jobs } = useSecurityJobs(false);
|
const { jobs } = useSecurityJobs(false);
|
||||||
const jobUrl = useKibana().services.application.getUrlForApp(
|
const {
|
||||||
`ml#/jobs?mlManagement=(jobId:${encodeURI(jobId)})`
|
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 job = jobs.find(({ id }) => id === jobId);
|
||||||
|
|
||||||
const jobIdSpan = <span data-test-subj="machineLearningJobId">{jobId}</span>;
|
const jobIdSpan = <span data-test-subj="machineLearningJobId">{jobId}</span>;
|
||||||
|
|
|
@ -23,12 +23,14 @@ import { SecurityPluginSetup } from '../../security/public';
|
||||||
import { AppFrontendLibs } from './common/lib/lib';
|
import { AppFrontendLibs } from './common/lib/lib';
|
||||||
import { ResolverPluginSetup } from './resolver/types';
|
import { ResolverPluginSetup } from './resolver/types';
|
||||||
import { Inspect } from '../common/search_strategy';
|
import { Inspect } from '../common/search_strategy';
|
||||||
|
import { MlPluginSetup, MlPluginStart } from '../../ml/public';
|
||||||
|
|
||||||
export interface SetupPlugins {
|
export interface SetupPlugins {
|
||||||
home?: HomePublicPluginSetup;
|
home?: HomePublicPluginSetup;
|
||||||
security: SecurityPluginSetup;
|
security: SecurityPluginSetup;
|
||||||
triggers_actions_ui: TriggersActionsSetup;
|
triggers_actions_ui: TriggersActionsSetup;
|
||||||
usageCollection?: UsageCollectionSetup;
|
usageCollection?: UsageCollectionSetup;
|
||||||
|
ml?: MlPluginSetup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StartPlugins {
|
export interface StartPlugins {
|
||||||
|
@ -40,6 +42,7 @@ export interface StartPlugins {
|
||||||
newsfeed?: NewsfeedStart;
|
newsfeed?: NewsfeedStart;
|
||||||
triggers_actions_ui: TriggersActionsStart;
|
triggers_actions_ui: TriggersActionsStart;
|
||||||
uiActions: UiActionsStart;
|
uiActions: UiActionsStart;
|
||||||
|
ml?: MlPluginStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StartServices = CoreStart &
|
export type StartServices = CoreStart &
|
||||||
|
|
Loading…
Reference in a new issue