[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 {
|
||||
"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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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'))"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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))"`
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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 },
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -55,7 +55,7 @@ export type MlGenericUrlState = MLPageState<
|
|||
>;
|
||||
|
||||
export interface AnomalyDetectionQueryState {
|
||||
jobId?: JobId;
|
||||
jobId?: JobId | string[];
|
||||
groupIds?: string[];
|
||||
globalState?: MlCommonGlobalState;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'),
|
||||
|
|
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 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() {}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -32,5 +32,5 @@
|
|||
],
|
||||
"server": 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 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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 &
|
||||
|
|
Loading…
Reference in a new issue