mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ML] Support pipelines deletion and force flag for delete action (#158671)
This commit is contained in:
parent
75ec1ec7c3
commit
21f42fb27b
15 changed files with 244 additions and 73 deletions
|
@ -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> };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: (
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 : (
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
@ -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 }))
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 })),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "モデルを削除",
|
||||
|
|
|
@ -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": "删除模型",
|
||||
|
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue