Skip to content

Commit

Permalink
[ML] Sync ML saved objects to all spaces (#202175)
Browse files Browse the repository at this point in the history
When manually syncing ML saved objects using the sync flyout, the saved
objects are now tagged to the `*` space. This now matches the behaviour
of the server side auto sync and the sync which happens when the trained
models page is loaded.
The trained models page load sync has been extended to the AD and DA
jobs lists and the overview page.

If the user does not have write permission for ML in every space they
cannot sync jobs to the `*` space.
In this situation a warning is shown in the flyout and when they sync,
the jobs/models will only be added to the current space.

![image](https://github.com/user-attachments/assets/9e6ede10-d7aa-4724-9b1c-adabe96593a8)

(cherry picked from commit 3d65e89)
  • Loading branch information
jgowdyelastic committed Jan 7, 2025
1 parent 567b237 commit bef9cfd
Show file tree
Hide file tree
Showing 19 changed files with 265 additions and 51 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export interface CanDeleteMLSpaceAwareItemsResponse {
};
}

export interface CanSyncToAllSpacesResponse {
canSync: boolean;
}

export type JobsSpacesResponse = {
[jobType in JobType]: { [jobId: string]: string[] };
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useMlApi } from '../../contexts/kibana';
import type { SyncSavedObjectResponse, SyncResult } from '../../../../common/types/saved_objects';
import { SyncList } from './sync_list';
import { useToastNotificationService } from '../../services/toast_notification_service';
import { SyncToAllSpacesWarning } from './sync_to_all_spaces_warning';

export interface Props {
onClose: () => void;
Expand All @@ -37,17 +38,22 @@ export const JobSpacesSyncFlyout: FC<Props> = ({ onClose }) => {
const { displayErrorToast, displaySuccessToast } = useToastNotificationService();
const [loading, setLoading] = useState(false);
const [canSync, setCanSync] = useState(false);
const [canSyncToAllSpaces, setCanSyncToAllSpaces] = useState(true);
const [syncResp, setSyncResp] = useState<SyncSavedObjectResponse | null>(null);
const {
savedObjects: { syncSavedObjects },
savedObjects: { syncSavedObjects, canSyncToAllSpaces: canSyncToAllSpacesFunc },
} = useMlApi();

async function loadSyncList(simulate: boolean = true) {
setLoading(true);
try {
const resp = await syncSavedObjects(simulate);
const resp = await syncSavedObjects(simulate, canSyncToAllSpaces);
setSyncResp(resp);

if (simulate === true) {
setCanSyncToAllSpaces((await canSyncToAllSpacesFunc()).canSync);
}

const count = Object.values(resp).reduce((acc, cur) => acc + Object.keys(cur).length, 0);
setCanSync(count > 0);
setLoading(false);
Expand Down Expand Up @@ -118,6 +124,12 @@ export const JobSpacesSyncFlyout: FC<Props> = ({ onClose }) => {
/>
</EuiText>
</EuiCallOut>
{canSyncToAllSpaces === false ? (
<>
<EuiSpacer size="s" />
<SyncToAllSpacesWarning />
</>
) : null}
<EuiSpacer />
<SyncList syncItems={syncResp} />
</EuiFlyoutBody>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import React from 'react';
import type { FC } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiLink, EuiText } from '@elastic/eui';
import { useMlKibana } from '../../contexts/kibana/kibana_context';

export const SyncToAllSpacesWarning: FC = () => {
const {
services: {
docLinks: { links },
},
} = useMlKibana();
const docLink = links.security.kibanaPrivileges;
return (
<EuiCallOut
size="s"
iconType="help"
title={
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.allSpacesWarning.title"
defaultMessage="Sync can only add items to the current space"
/>
}
color="warning"
>
<EuiText size="s">
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.allSpacesWarning.description"
defaultMessage="Without {readAndWritePrivilegesLink} for all spaces you can only add jobs and trained models to the current space when syncing."
values={{
readAndWritePrivilegesLink: (
<EuiLink href={docLink} target="_blank">
<FormattedMessage
id="xpack.ml.management.syncSavedObjectsFlyout.privilegeWarningLink"
defaultMessage="read and write privileges"
/>
</EuiLink>
),
}}
/>
</EuiText>
</EuiCallOut>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ export const basicResolvers = (): Resolvers => ({
getMlNodeCount,
loadMlServerInfo,
});

export const initSavedObjects = async (mlApi: MlApi) => {
return mlApi.savedObjects.initSavedObjects().catch(() => {});
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { NavigateToPath } from '../../../contexts/kibana';
import type { MlRoute } from '../../router';
import { createPath, PageLoader } from '../../router';
import { useRouteResolver } from '../../use_resolver';
import { basicResolvers } from '../../resolvers';
import { basicResolvers, initSavedObjects } from '../../resolvers';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';

const Page = dynamic(async () => ({
Expand Down Expand Up @@ -45,7 +45,10 @@ export const analyticsJobsListRouteFactory = (
});

const PageWrapper: FC = () => {
const { context } = useRouteResolver('full', ['canGetDataFrameAnalytics'], basicResolvers());
const { context } = useRouteResolver('full', ['canGetDataFrameAnalytics'], {
...basicResolvers(),
initSavedObjects,
});
return (
<PageLoader context={context}>
<Page />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { useRouteResolver } from '../use_resolver';
import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
import { AnnotationUpdatesService } from '../../services/annotations_service';
import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context';
import { basicResolvers } from '../resolvers';
import { basicResolvers, initSavedObjects } from '../resolvers';

const JobsPage = dynamic(async () => ({
default: (await import('../../jobs/jobs_list')).JobsPage,
Expand All @@ -51,7 +51,10 @@ export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: st
});

const PageWrapper: FC = () => {
const { context } = useRouteResolver('full', ['canGetJobs'], basicResolvers());
const { context } = useRouteResolver('full', ['canGetJobs'], {
...basicResolvers(),
initSavedObjects,
});

const timefilter = useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getBreadcrumbWithUrlForApp } from '../breadcrumbs';
import type { MlRoute, PageProps } from '../router';
import { createPath, PageLoader } from '../router';
import { useRouteResolver } from '../use_resolver';
import { initSavedObjects } from '../resolvers';

const OverviewPage = React.lazy(() => import('../../overview/overview_page'));

Expand Down Expand Up @@ -48,6 +49,7 @@ const PageWrapper: FC<PageProps> = () => {
const { context } = useRouteResolver('full', ['canGetMlInfo'], {
getMlNodeCount,
loadMlServerInfo,
initSavedObjects,
});

useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import { type FC, useCallback } from 'react';
import type { FC } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
Expand All @@ -16,10 +16,9 @@ import type { NavigateToPath } from '../../../contexts/kibana';
import type { MlRoute } from '../../router';
import { createPath, PageLoader } from '../../router';
import { useRouteResolver } from '../../use_resolver';
import { basicResolvers } from '../../resolvers';
import { basicResolvers, initSavedObjects } from '../../resolvers';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
import { MlPageHeader } from '../../../components/page_header';
import { useSavedObjectsApiService } from '../../../services/ml_api_service/saved_objects';

const ModelsList = dynamic(async () => ({
default: (await import('../../../model_management/models_list')).ModelsList,
Expand Down Expand Up @@ -49,19 +48,9 @@ export const modelsListRouteFactory = (
});

const PageWrapper: FC = () => {
const { initSavedObjects } = useSavedObjectsApiService();

const initSavedObjectsWrapper = useCallback(async () => {
try {
await initSavedObjects();
} catch (error) {
// ignore error as user may not have permission to sync
}
}, [initSavedObjects]);

const { context } = useRouteResolver('full', ['canGetTrainedModels'], {
...basicResolvers(),
initSavedObjectsWrapper,
initSavedObjects,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
JobsSpacesResponse,
TrainedModelsSpacesResponse,
SyncCheckResponse,
CanSyncToAllSpacesResponse,
} from '../../../../common/types/saved_objects';

export const savedObjectsApiProvider = (httpService: HttpService) => ({
Expand Down Expand Up @@ -56,11 +57,11 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({
version: '1',
});
},
syncSavedObjects(simulate: boolean = false) {
syncSavedObjects(simulate: boolean = false, addToAllSpaces?: boolean) {
return httpService.http<SyncSavedObjectResponse>({
path: `${ML_EXTERNAL_BASE_PATH}/saved_objects/sync`,
method: 'GET',
query: { simulate },
query: { simulate, addToAllSpaces },
version: '2023-10-31',
});
},
Expand Down Expand Up @@ -90,6 +91,15 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({
version: '1',
});
},
canSyncToAllSpaces(mlSavedObjectType?: MlSavedObjectType) {
return httpService.http<CanSyncToAllSpacesResponse>({
path: `${ML_INTERNAL_BASE_PATH}/saved_objects/can_sync_to_all_spaces${
mlSavedObjectType !== undefined ? `/${mlSavedObjectType}` : ''
}`,
method: 'GET',
version: '1',
});
},
trainedModelsSpaces() {
return httpService.http<TrainedModelsSpacesResponse>({
path: `${ML_INTERNAL_BASE_PATH}/saved_objects/trained_models_spaces`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -870,8 +870,8 @@ export class DataRecognizer {
);
if (applyToAllSpaces === true) {
const canCreateGlobalJobs = await this._mlSavedObjectService.canCreateGlobalMlSavedObjects(
'anomaly-detector',
this._request
this._request,
'anomaly-detector'
);
if (canCreateGlobalJobs === true) {
await this._mlSavedObjectService.updateJobsSpaces(
Expand Down
46 changes: 44 additions & 2 deletions x-pack/platform/plugins/shared/ml/server/routes/saved_objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ export function savedObjectsRoutes(
routeGuard.fullLicenseAPIGuard(
async ({ client, request, response, mlSavedObjectService }) => {
try {
const { simulate } = request.query;
const { simulate, addToAllSpaces } = request.query;
const { syncSavedObjects } = syncSavedObjectsFactory(client, mlSavedObjectService);
const savedObjects = await syncSavedObjects(simulate);
const savedObjects = await syncSavedObjects(simulate, addToAllSpaces ?? true);

return response.ok({
body: savedObjects,
Expand Down Expand Up @@ -450,4 +450,46 @@ export function savedObjectsRoutes(
}
)
);

router.versioned
.get({
path: `${ML_INTERNAL_BASE_PATH}/saved_objects/can_sync_to_all_spaces/{mlSavedObjectType?}`,
access: 'internal',
security: {
authz: {
requiredPrivileges: [
'ml:canGetJobs',
'ml:canGetDataFrameAnalytics',
'ml:canGetTrainedModels',
],
},
},
summary: 'Check whether user can sync a job or trained model to the * space',
description: `Check the user's ability to sync jobs or trained models to the * space. Returns whether they are able to sync the job or trained model to the * space.`,
})
.addVersion(
{
version: '1',
validate: {
request: {
params: syncCheckSchema,
},
},
},
routeGuard.fullLicenseAPIGuard(async ({ request, response, mlSavedObjectService }) => {
try {
const { mlSavedObjectType } = request.params;
const canSync = await mlSavedObjectService.canCreateGlobalMlSavedObjects(
request,
mlSavedObjectType as MlSavedObjectType
);

return response.ok({
body: { canSync },
});
} catch (e) {
return response.customError(wrapError(e));
}
})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ export const itemsAndCurrentSpace = schema.object({
ids: schema.arrayOf(schema.string()),
});

export const syncJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) });
export const syncJobObjects = schema.object({
simulate: schema.maybe(schema.boolean()),
addToAllSpaces: schema.maybe(schema.boolean()),
});

export const syncCheckSchema = schema.object({ mlSavedObjectType: schema.maybe(schema.string()) });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,8 @@ export function checksFactory(
}, {} as DeleteMLSpaceAwareItemsCheckResponse);
}
const canCreateGlobalMlSavedObjects = await mlSavedObjectService.canCreateGlobalMlSavedObjects(
mlSavedObjectType,
request
request,
mlSavedObjectType
);

const savedObjects =
Expand Down
Loading

0 comments on commit bef9cfd

Please sign in to comment.