diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index 822c5059982e7..381528e1055d6 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -135,10 +135,13 @@ export function trainedModelsApiProvider(httpService: HttpService) { }); }, - stopModelAllocation(modelId: string) { + stopModelAllocation(modelId: string, options: { force: boolean } = { force: false }) { + const force = options?.force; + return httpService.http<{ acknowledge: boolean }>({ path: `${apiBasePath}/trained_models/${modelId}/deployment/_stop`, method: 'POST', + query: { force }, }); }, }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx new file mode 100644 index 0000000000000..f6389854d4a13 --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/force_stop_dialog.tsx @@ -0,0 +1,89 @@ +/* + * 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, { FC } from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { OverlayStart } from 'kibana/public'; +import type { ModelItem } from './models_list'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; + +interface ForceStopModelConfirmDialogProps { + model: ModelItem; + onCancel: () => void; + onConfirm: () => void; +} + +export const ForceStopModelConfirmDialog: FC = ({ + model, + onConfirm, + onCancel, +}) => { + return ( + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + > + +
    + {Object.keys(model.pipelines!) + .sort() + .map((pipelineName) => { + return
  • {pipelineName}
  • ; + })} +
+
+ ); +}; + +export const getUserConfirmationProvider = + (overlays: OverlayStart) => async (forceStopModel: ModelItem) => { + return new Promise(async (resolve, reject) => { + try { + const modalSession = overlays.openModal( + toMountPoint( + { + modalSession.close(); + resolve(false); + }} + onConfirm={() => { + modalSession.close(); + resolve(true); + }} + /> + ) + ); + } catch (e) { + resolve(false); + } + }); + }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index ce0e47df292de..9e0ec639ef7c6 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -53,6 +53,7 @@ import { useFieldFormatter } from '../../contexts/kibana/use_field_formatter'; import { FIELD_FORMAT_IDS } from '../../../../../../../src/plugins/field_formats/common'; import { useRefresh } from '../../routing/use_refresh'; import { DEPLOYMENT_STATE } from '../../../../common/constants/trained_models'; +import { getUserConfirmationProvider } from './force_stop_dialog'; type Stats = Omit; @@ -80,6 +81,7 @@ export const ModelsList: FC = () => { const { services: { application: { navigateToUrl, capabilities }, + overlays, }, } = useMlKibana(); const urlLocator = useMlLocator()!; @@ -110,6 +112,8 @@ export const ModelsList: FC = () => { {} ); + const getUserConfirmation = useMemo(() => getUserConfirmationProvider(overlays), []); + const navigateToPath = useNavigateToPath(); const isBuiltInModel = useCallback( @@ -418,13 +422,21 @@ export const ModelsList: FC = () => { available: (item) => item.model_type === 'pytorch', enabled: (item) => !isLoading && - !isPopulatedObject(item.pipelines) && isPopulatedObject(item.stats?.deployment_stats) && item.stats?.deployment_stats?.state !== DEPLOYMENT_STATE.STOPPING, onClick: async (item) => { + const requireForceStop = isPopulatedObject(item.pipelines); + + if (requireForceStop) { + const hasUserApproved = await getUserConfirmation(item); + if (!hasUserApproved) return; + } + try { setIsLoading(true); - await trainedModelsApiService.stopModelAllocation(item.model_id); + await trainedModelsApiService.stopModelAllocation(item.model_id, { + force: requireForceStop, + }); displaySuccessToast( i18n.translate('xpack.ml.trainedModels.modelsList.stopSuccess', { defaultMessage: 'Deployment for "{modelId}" has been stopped successfully.', diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 920e1f703422d..c72b4d5cb5dd7 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -51,7 +51,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(31); + expect(count).toBe(32); }); }); @@ -101,6 +101,7 @@ describe('check_capabilities', () => { expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); expect(capabilities.canCreateMlAlerts).toBe(false); + expect(capabilities.canViewMlNodes).toBe(false); }); test('full capabilities', async () => { @@ -146,6 +147,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(true); expect(capabilities.canCreateDataFrameAnalytics).toBe(true); expect(capabilities.canStartStopDataFrameAnalytics).toBe(true); + expect(capabilities.canViewMlNodes).toBe(true); }); test('upgrade in progress with full capabilities', async () => { diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index b4a1f1c833403..0b57f5eec25db 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -16,6 +16,7 @@ import { modelsProvider } from '../models/data_frame_analytics'; import { TrainedModelConfigResponse } from '../../common/types/trained_models'; import { memoryOverviewServiceProvider } from '../models/memory_overview'; import { mlLog } from '../lib/log'; +import { forceQuerySchema } from './schemas/anomaly_detectors_schema'; export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) { /** @@ -262,6 +263,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) path: '/api/ml/trained_models/{modelId}/deployment/_stop', validate: { params: modelIdSchema, + query: forceQuerySchema, }, options: { tags: ['access:ml:canGetDataFrameAnalytics'], @@ -272,6 +274,7 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) const { modelId } = request.params; const { body } = await mlClient.stopTrainedModelDeployment({ model_id: modelId, + force: request.query.force ?? false, }); return response.ok({ body,