[ML] Support pipelines deletion and force flag for delete action (#158671)

This commit is contained in:
Dima Arnautov 2023-06-05 13:46:27 +02:00 committed by GitHub
parent 75ec1ec7c3
commit 21f42fb27b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 244 additions and 73 deletions

View file

@ -52,3 +52,8 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type AwaitReturnType<T> = T extends PromiseLike<infer U> ? U : T;
/**
* Removes an optional modifier from a property in a type.
*/
export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], null> };

View file

@ -5,36 +5,58 @@
* 2.0.
*/
import React, { FC, useState, useCallback } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCheckbox,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalFooter,
EuiButtonEmpty,
EuiButton,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { type WithRequired } from '../../../common/types/common';
import { useTrainedModelsApiService } from '../services/ml_api_service/trained_models';
import { useToastNotificationService } from '../services/toast_notification_service';
import { DeleteSpaceAwareItemCheckModal } from '../components/delete_space_aware_item_check_modal';
import { type ModelItem } from './models_list';
interface DeleteModelsModalProps {
modelIds: string[];
models: ModelItem[];
onClose: (refreshList?: boolean) => void;
}
export const DeleteModelsModal: FC<DeleteModelsModalProps> = ({ modelIds, onClose }) => {
export const DeleteModelsModal: FC<DeleteModelsModalProps> = ({ models, onClose }) => {
const trainedModelsApiService = useTrainedModelsApiService();
const { displayErrorToast, displaySuccessToast } = useToastNotificationService();
const [canDeleteModel, setCanDeleteModel] = useState(false);
const [deletePipelines, setDeletePipelines] = useState<boolean>(false);
const modelIds = models.map((m) => m.model_id);
const modelsWithPipelines = models.filter((m) => isPopulatedObject(m.pipelines)) as Array<
WithRequired<ModelItem, 'pipelines'>
>;
const pipelinesCount = modelsWithPipelines.reduce((acc, curr) => {
return acc + Object.keys(curr.pipelines).length;
}, 0);
const deleteModels = useCallback(async () => {
try {
await Promise.all(
modelIds.map((modelId) => trainedModelsApiService.deleteTrainedModel(modelId))
modelIds.map((modelId) =>
trainedModelsApiService.deleteTrainedModel(modelId, {
with_pipelines: deletePipelines,
force: pipelinesCount > 0,
})
)
);
displaySuccessToast(
i18n.translate('xpack.ml.trainedModels.modelsList.successfullyDeletedMessage', {
@ -59,7 +81,7 @@ export const DeleteModelsModal: FC<DeleteModelsModalProps> = ({ modelIds, onClos
}
onClose(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modelIds, trainedModelsApiService]);
}, [modelIds, trainedModelsApiService, deletePipelines, pipelinesCount]);
return canDeleteModel ? (
<EuiModal
@ -80,6 +102,55 @@ export const DeleteModelsModal: FC<DeleteModelsModalProps> = ({ modelIds, onClos
</EuiModalHeaderTitle>
</EuiModalHeader>
{modelsWithPipelines.length > 0 ? (
<EuiModalBody>
<EuiCallOut
title={
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.deleteModal.pipelinesWarningHeader"
defaultMessage="{modelsCount, plural, one {{modelId} has} other {# models have}} associated pipelines."
values={{
modelsCount: modelsWithPipelines.length,
modelId: modelsWithPipelines[0].model_id,
}}
/>
}
color="warning"
iconType="warning"
>
<div>
<p>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.deleteModal.warningMessage"
defaultMessage="Deleting the trained model and its associated {pipelinesCount, plural, one {pipeline} other {pipelines}} will permanently remove these resources. Any process configured to send data to the {pipelinesCount, plural, one {pipeline} other {pipelines}} will no longer be able to do so once you delete the {pipelinesCount, plural, one {pipeline} other {pipelines}}. Deleting only the trained model will cause failures in the {pipelinesCount, plural, one {pipeline} other {pipelines}} that {pipelinesCount, plural, one {depends} other {depend}} on the model."
values={{ pipelinesCount }}
/>
</p>
<EuiCheckbox
id={'delete-model-pipelines'}
label={
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.deleteModal.approvePipelinesDeletionLabel"
defaultMessage="Delete {pipelinesCount, plural, one {pipeline} other {pipelines}}"
values={{ pipelinesCount }}
/>
}
checked={deletePipelines}
onChange={setDeletePipelines.bind(null, (prev) => !prev)}
data-test-subj="mlModelsDeleteModalDeletePipelinesCheckbox"
/>
</div>
<ul>
{modelsWithPipelines.flatMap((model) => {
return Object.keys(model.pipelines).map((pipelineId) => (
<li key={pipelineId}>{pipelineId}</li>
));
})}
</ul>
</EuiCallOut>
</EuiModalBody>
) : null}
<EuiModalFooter>
<EuiButtonEmpty onClick={onClose.bind(null, false)} name="cancelModelDeletion">
<FormattedMessage

View file

@ -385,9 +385,9 @@ export const ExpandedRow: FC<ExpandedRowProps> = ({ item }) => {
id="xpack.ml.trainedModels.modelsList.expandedRow.pipelinesTabLabel"
defaultMessage="Pipelines"
/>
<EuiNotificationBadge>
{isPopulatedObject(pipelines) ? Object.keys(pipelines!).length : 0}
</EuiNotificationBadge>
{isPopulatedObject(pipelines) ? (
<EuiNotificationBadge>{Object.keys(pipelines).length}</EuiNotificationBadge>
) : null}
</>
),
content: (

View file

@ -150,13 +150,13 @@ export const StopModelDeploymentsConfirmDialog: FC<ForceStopModelConfirmDialogPr
color="warning"
iconType="warning"
>
<p>
<div>
<ul>
{pipelineWarning.map((pipelineName) => {
return <li key={pipelineName}>{pipelineName}</li>;
})}
</ul>
</p>
</div>
</EuiCallOut>
</>
) : null}

View file

@ -9,7 +9,7 @@ import { Action } from '@elastic/eui/src/components/basic_table/action_types';
import { i18n } from '@kbn/i18n';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { EuiToolTip } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useEffect, useState } from 'react';
import {
BUILT_IN_MODEL_TAG,
DEPLOYMENT_STATE,
@ -42,7 +42,7 @@ export function useModelActions({
}: {
isLoading: boolean;
onTestAction: (model: ModelItem) => void;
onModelsDeleteRequest: (modelsIds: string[]) => void;
onModelsDeleteRequest: (models: ModelItem[]) => void;
onLoading: (isLoading: boolean) => void;
fetchModels: () => Promise<void>;
modelAndDeploymentIds: string[];
@ -53,9 +53,12 @@ export function useModelActions({
overlays,
theme,
docLinks,
mlServices: { mlApiServices },
},
} = useMlKibana();
const [canManageIngestPipelines, setCanManageIngestPipelines] = useState(false);
const startModelDeploymentDocUrl = docLinks.links.ml.startTrainedModelsDeployment;
const navigateToPath = useNavigateToPath();
@ -70,6 +73,23 @@ export function useModelActions({
const canTestTrainedModels = capabilities.ml.canTestTrainedModels as boolean;
const canDeleteTrainedModels = capabilities.ml.canDeleteTrainedModels as boolean;
useEffect(() => {
let isMounted = true;
mlApiServices
.hasPrivileges({
cluster: ['manage_ingest_pipelines'],
})
.then((result) => {
const canManagePipelines = result.cluster.manage_ingest_pipelines;
if (isMounted) {
setCanManageIngestPipelines(canManagePipelines);
}
});
return () => {
isMounted = false;
};
}, [mlApiServices]);
const getUserConfirmation = useMemo(
() => getUserConfirmationProvider(overlays, theme),
[overlays, theme]
@ -394,17 +414,12 @@ export function useModelActions({
},
{
name: (model) => {
const hasPipelines = isPopulatedObject(model.pipelines);
const hasDeployments = model.state === MODEL_STATE.STARTED;
return (
<EuiToolTip
position="left"
content={
hasPipelines
? i18n.translate('xpack.ml.trainedModels.modelsList.deleteDisabledTooltip', {
defaultMessage: 'Model has associated pipelines',
})
: hasDeployments
hasDeployments
? i18n.translate(
'xpack.ml.trainedModels.modelsList.deleteDisabledWithDeploymentsTooltip',
{
@ -431,14 +446,19 @@ export function useModelActions({
color: 'danger',
isPrimary: false,
onClick: (model) => {
onModelsDeleteRequest([model.model_id]);
onModelsDeleteRequest([model]);
},
available: (item) => {
const hasZeroPipelines = Object.keys(item.pipelines ?? {}).length === 0;
return (
canDeleteTrainedModels &&
!isBuiltInModel(item) &&
!item.putModelConfig &&
(hasZeroPipelines || canManageIngestPipelines)
);
},
available: (item) =>
canDeleteTrainedModels && !isBuiltInModel(item) && !item.putModelConfig,
enabled: (item) => {
// TODO check for permissions to delete ingest pipelines.
// ATM undefined means pipelines fetch failed server-side.
return item.state !== MODEL_STATE.STARTED && !isPopulatedObject(item.pipelines);
return item.state !== MODEL_STATE.STARTED;
},
},
{
@ -476,6 +496,7 @@ export function useModelActions({
isBuiltInModel,
onTestAction,
canTestTrainedModels,
canManageIngestPipelines,
]
);
}

View file

@ -133,7 +133,7 @@ export const ModelsList: FC<Props> = ({
const [isLoading, setIsLoading] = useState(false);
const [items, setItems] = useState<ModelItem[]>([]);
const [selectedModels, setSelectedModels] = useState<ModelItem[]>([]);
const [modelIdsToDelete, setModelIdsToDelete] = useState<string[]>([]);
const [modelsToDelete, setModelsToDelete] = useState<ModelItem[]>([]);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<Record<string, JSX.Element>>(
{}
);
@ -348,7 +348,7 @@ export const ModelsList: FC<Props> = ({
isLoading,
fetchModels: fetchModelsData,
onTestAction: setModelToTest,
onModelsDeleteRequest: setModelIdsToDelete,
onModelsDeleteRequest: setModelsToDelete,
onLoading: setIsLoading,
modelAndDeploymentIds,
});
@ -502,13 +502,7 @@ export const ModelsList: FC<Props> = ({
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
color="danger"
onClick={setModelIdsToDelete.bind(
null,
selectedModels.map((m) => m.model_id)
)}
>
<EuiButton color="danger" onClick={setModelsToDelete.bind(null, selectedModels)}>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.deleteModelsButtonLabel"
defaultMessage="Delete"
@ -634,15 +628,15 @@ export const ModelsList: FC<Props> = ({
data-test-subj={isLoading ? 'mlModelsTable loading' : 'mlModelsTable loaded'}
/>
</div>
{modelIdsToDelete.length > 0 && (
{modelsToDelete.length > 0 && (
<DeleteModelsModal
onClose={(refreshList) => {
setModelIdsToDelete([]);
setModelsToDelete([]);
if (refreshList) {
fetchModelsData();
}
}}
modelIds={modelIdsToDelete}
models={modelsToDelete}
/>
)}
{modelToTest === null ? null : (

View file

@ -114,11 +114,18 @@ export function trainedModelsApiProvider(httpService: HttpService) {
*
* @param modelId - Model ID
*/
deleteTrainedModel(modelId: string) {
deleteTrainedModel(
modelId: string,
options: { with_pipelines?: boolean; force?: boolean } = {
with_pipelines: false,
force: false,
}
) {
return httpService.http<{ acknowledge: boolean }>({
path: `${ML_INTERNAL_BASE_PATH}/trained_models/${modelId}`,
method: 'DELETE',
version: '1',
query: options,
});
},

View file

@ -6,7 +6,6 @@
*/
import type { IScopedClusterClient } from '@kbn/core/server';
import type { PipelineDefinition } from '../../../common/types/trained_models';
export type ModelService = ReturnType<typeof modelsProvider>;
@ -51,5 +50,19 @@ export function modelsProvider(client: IScopedClusterClient) {
return modelIdsMap;
},
/**
* Deletes associated pipelines of the requested model
* @param modelIds
*/
async deleteModelPipelines(modelIds: string[]) {
const pipelines = await this.getModelsPipelines(modelIds);
const pipelinesIds: string[] = [
...new Set([...pipelines.values()].flatMap((v) => Object.keys(v!))),
];
await Promise.all(
pipelinesIds.map((id) => client.asCurrentUser.ingest.deletePipeline({ id }))
);
},
};
}

View file

@ -72,3 +72,8 @@ export const stopDeploymentSchema = schema.object({
/** force stop */
force: schema.maybe(schema.boolean()),
});
export const deleteTrainedModelQuerySchema = schema.object({
with_pipelines: schema.maybe(schema.boolean({ defaultValue: false })),
force: schema.maybe(schema.boolean({ defaultValue: false })),
});

View file

@ -11,6 +11,7 @@ import { ML_INTERNAL_BASE_PATH } from '../../common/constants/app';
import { RouteInitialization } from '../types';
import { wrapError } from '../client/error_wrapper';
import {
deleteTrainedModelQuerySchema,
getInferenceQuerySchema,
inferTrainedModelBody,
inferTrainedModelQuery,
@ -22,7 +23,6 @@ import {
threadingParamsSchema,
updateDeploymentParamsSchema,
} from './schemas/inference_schema';
import { TrainedModelConfigResponse } from '../../common/types/trained_models';
import { mlLog } from '../lib/log';
import { forceQuerySchema } from './schemas/anomaly_detectors_schema';
@ -303,15 +303,25 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization)
validate: {
request: {
params: modelIdSchema,
query: deleteTrainedModelQuerySchema,
},
},
},
routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => {
routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response, client }) => {
try {
const { modelId } = request.params;
const { with_pipelines: withPipelines, force } = request.query;
if (withPipelines) {
// first we need to delete pipelines, otherwise ml api return an error
await modelsProvider(client).deleteModelPipelines(modelId.split(','));
}
const body = await mlClient.deleteTrainedModel({
model_id: modelId,
force,
});
return response.ok({
body,
});

View file

@ -24943,7 +24943,6 @@
"xpack.ml.trainedModels.modelsList.builtInModelMessage": "Modèle intégré",
"xpack.ml.trainedModels.modelsList.collapseRow": "Réduire",
"xpack.ml.trainedModels.modelsList.createdAtHeader": "Créé à",
"xpack.ml.trainedModels.modelsList.deleteDisabledTooltip": "Le modèle a des pipelines associés",
"xpack.ml.trainedModels.modelsList.deleteModal.cancelButtonLabel": "Annuler",
"xpack.ml.trainedModels.modelsList.deleteModal.deleteButtonLabel": "Supprimer",
"xpack.ml.trainedModels.modelsList.deleteModelActionLabel": "Supprimer le modèle",

View file

@ -24929,7 +24929,6 @@
"xpack.ml.trainedModels.modelsList.builtInModelMessage": "ビルトインモデル",
"xpack.ml.trainedModels.modelsList.collapseRow": "縮小",
"xpack.ml.trainedModels.modelsList.createdAtHeader": "作成日時:",
"xpack.ml.trainedModels.modelsList.deleteDisabledTooltip": "モデルにはパイプラインが関連付けられています",
"xpack.ml.trainedModels.modelsList.deleteModal.cancelButtonLabel": "キャンセル",
"xpack.ml.trainedModels.modelsList.deleteModal.deleteButtonLabel": "削除",
"xpack.ml.trainedModels.modelsList.deleteModelActionLabel": "モデルを削除",

View file

@ -24928,7 +24928,6 @@
"xpack.ml.trainedModels.modelsList.builtInModelMessage": "内置模型",
"xpack.ml.trainedModels.modelsList.collapseRow": "折叠",
"xpack.ml.trainedModels.modelsList.createdAtHeader": "创建于",
"xpack.ml.trainedModels.modelsList.deleteDisabledTooltip": "模型有关联的管道",
"xpack.ml.trainedModels.modelsList.deleteModal.cancelButtonLabel": "取消",
"xpack.ml.trainedModels.modelsList.deleteModal.deleteButtonLabel": "删除",
"xpack.ml.trainedModels.modelsList.deleteModelActionLabel": "删除模型",

View file

@ -52,6 +52,28 @@ export default function ({ getService }: FtrProviderContext) {
modelTypes: ['regression', 'tree_ensemble'],
};
describe('for ML user with read-only access', () => {
before(async () => {
await ml.securityUI.loginAsMlViewer();
await ml.navigation.navigateToTrainedModels();
await ml.commonUI.waitForRefreshButtonEnabled();
});
after(async () => {
await ml.securityUI.logout();
});
it('renders expanded row content correctly for model with pipelines', async () => {
await ml.trainedModelsTable.ensureRowIsExpanded(modelWithPipelineData.modelId);
await ml.trainedModelsTable.assertDetailsTabContent();
await ml.trainedModelsTable.assertInferenceConfigTabContent();
await ml.trainedModelsTable.assertStatsTabContent();
await ml.trainedModelsTable.assertPipelinesTabContent(true, [
{ pipelineName: `pipeline_${modelWithPipelineData.modelId}`, expectDefinition: false },
]);
});
});
describe('for ML power user', () => {
before(async () => {
await ml.securityUI.loginAsMlPowerUser();
@ -138,7 +160,7 @@ export default function ({ getService }: FtrProviderContext) {
);
});
it('displays a model with an ingest pipeline and delete action is disabled', async () => {
it('displays a model with an ingest pipeline and model can be deleted with associated ingest pipelines', async () => {
await ml.testExecution.logTestStep('should display the model in the table');
await ml.trainedModelsTable.filterWithSearchString(modelWithPipelineData.modelId, 1);
@ -152,10 +174,20 @@ export default function ({ getService }: FtrProviderContext) {
});
await ml.testExecution.logTestStep(
'should show disabled delete action for the model in the table'
'should show enabled delete action for the model in the table'
);
await ml.trainedModelsTable.assertModelDeleteActionButtonEnabled(
modelWithPipelineData.modelId,
true
);
await ml.testExecution.logTestStep('should show the delete modal');
await ml.trainedModelsTable.clickDeleteAction(modelWithPipelineData.modelId);
await ml.testExecution.logTestStep('should delete the model with pipelines');
await ml.trainedModelsTable.confirmDeleteModel(true);
await ml.trainedModelsTable.assertModelDisplayedInTable(
modelWithPipelineData.modelId,
false
);
@ -224,27 +256,5 @@ export default function ({ getService }: FtrProviderContext) {
}
});
});
describe('for ML user with read-only access', () => {
before(async () => {
await ml.securityUI.loginAsMlViewer();
await ml.navigation.navigateToTrainedModels();
await ml.commonUI.waitForRefreshButtonEnabled();
});
after(async () => {
await ml.securityUI.logout();
});
it('renders expanded row content correctly for model with pipelines', async () => {
await ml.trainedModelsTable.ensureRowIsExpanded(modelWithPipelineData.modelId);
await ml.trainedModelsTable.assertDetailsTabContent();
await ml.trainedModelsTable.assertInferenceConfigTabContent();
await ml.trainedModelsTable.assertStatsTabContent();
await ml.trainedModelsTable.assertPipelinesTabContent(true, [
{ pipelineName: `pipeline_${modelWithPipelineData.modelId}`, expectDefinition: false },
]);
});
});
});
}

View file

@ -284,16 +284,54 @@ export function TrainedModelsTableProvider(
await testSubjects.missingOrFail('mlModelsDeleteModal', { timeout: 60 * 1000 });
}
public async confirmDeleteModel() {
public async getCheckBoxState(testSubj: string): Promise<boolean> {
return (await testSubjects.getAttribute(testSubj, 'checked')) === 'true';
}
public async assertDeletePipelinesCheckboxSelected(expectedValue: boolean) {
const actualCheckState = await this.getCheckBoxState(
'mlModelsDeleteModalDeletePipelinesCheckbox'
);
expect(actualCheckState).to.eql(
expectedValue,
`Delete model pipelines checkbox should be ${expectedValue} (got ${actualCheckState})`
);
}
public async setDeletePipelinesCheckbox() {
await this.assertDeletePipelinesCheckboxSelected(false);
const checkboxLabel = await find.byCssSelector(`label[for="delete-model-pipelines"]`);
await checkboxLabel.click();
await this.assertDeletePipelinesCheckboxSelected(true);
}
public async confirmDeleteModel(withPipelines: boolean = false) {
await retry.tryForTime(30 * 1000, async () => {
await this.assertDeleteModalExists();
if (withPipelines) {
await this.setDeletePipelinesCheckbox();
}
await testSubjects.click('mlModelsDeleteModalConfirmButton');
await this.assertDeleteModalNotExists();
});
}
public async clickDeleteAction(modelId: string) {
await testSubjects.click(this.rowSelector(modelId, 'mlModelsTableRowDeleteAction'));
const actionsButtonExists = await this.doesModelCollapsedActionsButtonExist(modelId);
if (actionsButtonExists) {
await this.toggleActionsContextMenu(modelId, true);
const panelElement = await find.byCssSelector('.euiContextMenuPanel');
const actionButton = await panelElement.findByTestSubject('mlModelsTableRowDeleteAction');
await actionButton.click();
} else {
await testSubjects.click(this.rowSelector(modelId, 'mlModelsTableRowDeleteAction'));
}
await this.assertDeleteModalExists();
}