[ML] Data Frame Analytics: check space permissions before deleting jobs (#85495)
* disable delete until checks complete * add canDeleteJobs wrapper in saved objects service * create DeleteJobCheckModal shared component * wip: add deleteJobModal check in list and map views * adding remove from current space endpoint * updating error text * fixing typo in variable name * Update button content. Add untagging functionality * adding anomaly detection delete job modal * fix modal content bug * refresh job map after deletion or untagging * adding job refresh to anomaly detectors * go straight to delete flow if only action available * fixing test * update text * increase line spacing in check modal Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: James Gowdy <jgowdy@elastic.co>
This commit is contained in:
parent
9b71c94ff1
commit
43e20112b1
|
@ -18,6 +18,13 @@ export interface SyncSavedObjectResponse {
|
|||
datafeedsRemoved: SavedObjectResult;
|
||||
}
|
||||
|
||||
export interface CanDeleteJobResponse {
|
||||
[jobId: string]: {
|
||||
canDelete: boolean;
|
||||
canUntag: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type JobsSpacesResponse = {
|
||||
[jobType in JobType]: { [jobId: string]: string[] };
|
||||
};
|
||||
|
|
|
@ -0,0 +1,297 @@
|
|||
/*
|
||||
* 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 React, { FC, useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiModal,
|
||||
EuiOverlayMask,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiButton,
|
||||
EuiLoadingSpinner,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { JobType, CanDeleteJobResponse } from '../../../../common/types/saved_objects';
|
||||
import { useMlApiContext } from '../../contexts/kibana';
|
||||
import { useToastNotificationService } from '../../services/toast_notification_service';
|
||||
|
||||
const shouldUnTagLabel = i18n.translate('xpack.ml.deleteJobCheckModal.shouldUnTagLabel', {
|
||||
defaultMessage: 'Remove job from current space',
|
||||
});
|
||||
|
||||
interface ModalContentReturnType {
|
||||
buttonText: JSX.Element;
|
||||
modalText: JSX.Element;
|
||||
}
|
||||
|
||||
interface JobCheckRespSummary {
|
||||
canDelete: boolean;
|
||||
canUntag: boolean;
|
||||
canTakeAnyAction: boolean;
|
||||
}
|
||||
|
||||
function getRespSummary(resp: CanDeleteJobResponse): JobCheckRespSummary {
|
||||
const jobsChecked = Object.keys(resp);
|
||||
// Default to first job's permissions
|
||||
const { canDelete, canUntag } = resp[jobsChecked[0]];
|
||||
let canTakeAnyAction = true;
|
||||
|
||||
if (jobsChecked.length > 1) {
|
||||
// Check all jobs and make sure they have the same permissions - otherwise no action can be taken
|
||||
canTakeAnyAction = jobsChecked.every(
|
||||
(id) => resp[id].canDelete === canDelete && resp[id].canUntag === canUntag
|
||||
);
|
||||
}
|
||||
|
||||
return { canDelete, canUntag, canTakeAnyAction };
|
||||
}
|
||||
|
||||
function getModalContent(
|
||||
jobIds: string[],
|
||||
respSummary: JobCheckRespSummary
|
||||
): ModalContentReturnType {
|
||||
const { canDelete, canUntag, canTakeAnyAction } = respSummary;
|
||||
|
||||
if (canTakeAnyAction === false) {
|
||||
return {
|
||||
buttonText: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.buttonTextNoAction"
|
||||
defaultMessage="Close"
|
||||
/>
|
||||
),
|
||||
modalText: (
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.modalTextNoAction"
|
||||
defaultMessage="{ids} have different space permissions. When you delete multiple jobs, they must have the same permissions. Deselect the jobs and try deleting each job individually."
|
||||
values={{ ids: jobIds.join(', ') }}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const noActionContent: ModalContentReturnType = {
|
||||
buttonText: (
|
||||
<FormattedMessage id="xpack.ml.deleteJobCheckModal.buttonTextClose" defaultMessage="Close" />
|
||||
),
|
||||
modalText: (
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.modalTextClose"
|
||||
defaultMessage="{ids} cannot be deleted and cannot be removed from the current space. This job is assigned to the * space and you do not have access to all spaces."
|
||||
values={{ ids: jobIds.join(', ') }}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
};
|
||||
|
||||
if (canDelete) {
|
||||
return {
|
||||
buttonText: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.buttonTextCanDelete"
|
||||
defaultMessage="Continue to delete {length, plural, one {# job} other {# jobs}}"
|
||||
values={{ length: jobIds.length }}
|
||||
/>
|
||||
),
|
||||
modalText: (
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.modalTextCanDelete"
|
||||
defaultMessage="{ids} can be deleted."
|
||||
values={{ ids: jobIds.join(', ') }}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
};
|
||||
} else if (canUntag) {
|
||||
return {
|
||||
buttonText: (
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.buttonTextCanUnTagConfirm"
|
||||
defaultMessage="Remove from current space"
|
||||
/>
|
||||
),
|
||||
modalText: (
|
||||
<EuiText>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.modalTextCanUnTag"
|
||||
defaultMessage="{ids} cannot be deleted but can be removed from the current space."
|
||||
values={{ ids: jobIds.join(', ') }}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return noActionContent;
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
canDeleteCallback: () => void;
|
||||
onCloseCallback: () => void;
|
||||
refreshJobsCallback?: () => void;
|
||||
jobType: JobType;
|
||||
jobIds: string[];
|
||||
setDidUntag?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const DeleteJobCheckModal: FC<Props> = ({
|
||||
canDeleteCallback,
|
||||
onCloseCallback,
|
||||
refreshJobsCallback,
|
||||
jobType,
|
||||
jobIds,
|
||||
setDidUntag,
|
||||
}) => {
|
||||
const [buttonContent, setButtonContent] = useState<JSX.Element | undefined>();
|
||||
const [modalContent, setModalContent] = useState<JSX.Element | undefined>();
|
||||
const [hasUntagged, setHasUntagged] = useState<boolean>(false);
|
||||
const [isUntagging, setIsUntagging] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [jobCheckRespSummary, setJobCheckRespSummary] = useState<JobCheckRespSummary | undefined>();
|
||||
|
||||
const {
|
||||
savedObjects: { canDeleteJob, removeJobFromCurrentSpace },
|
||||
} = useMlApiContext();
|
||||
const { displayErrorToast, displaySuccessToast } = useToastNotificationService();
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
// Do the spaces check and set the content for the modal and buttons depending on results
|
||||
canDeleteJob(jobType, jobIds).then((resp) => {
|
||||
const respSummary = getRespSummary(resp);
|
||||
const { canDelete, canUntag, canTakeAnyAction } = respSummary;
|
||||
if (canTakeAnyAction && canDelete && !canUntag) {
|
||||
// Go straight to delete flow if that's the only action available
|
||||
canDeleteCallback();
|
||||
return;
|
||||
}
|
||||
setJobCheckRespSummary(respSummary);
|
||||
const { buttonText, modalText } = getModalContent(jobIds, respSummary);
|
||||
setButtonContent(buttonText);
|
||||
setModalContent(modalText);
|
||||
});
|
||||
if (typeof setDidUntag === 'function') {
|
||||
setDidUntag(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const onUntagClick = async () => {
|
||||
setIsUntagging(true);
|
||||
const resp = await removeJobFromCurrentSpace(jobType, jobIds);
|
||||
setIsUntagging(false);
|
||||
if (typeof setDidUntag === 'function') {
|
||||
setDidUntag(true);
|
||||
}
|
||||
Object.entries(resp).forEach(([id, { success, error }]) => {
|
||||
if (success === false) {
|
||||
const title = i18n.translate('xpack.ml.deleteJobCheckModal.unTagErrorTitle', {
|
||||
defaultMessage: 'Error updating {id}',
|
||||
values: { id },
|
||||
});
|
||||
displayErrorToast(error, title);
|
||||
} else {
|
||||
setHasUntagged(true);
|
||||
const message = i18n.translate('xpack.ml.deleteJobCheckModal.unTagSuccessTitle', {
|
||||
defaultMessage: 'Successfully updated {id}',
|
||||
values: { id },
|
||||
});
|
||||
displaySuccessToast(message);
|
||||
}
|
||||
});
|
||||
// Close the modal
|
||||
onCloseCallback();
|
||||
if (typeof refreshJobsCallback === 'function') {
|
||||
refreshJobsCallback();
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
if (jobCheckRespSummary?.canTakeAnyAction && jobCheckRespSummary?.canDelete) {
|
||||
canDeleteCallback();
|
||||
} else {
|
||||
onCloseCallback();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiOverlayMask data-test-subj="mlAnalyticsJobDeleteCheckOverlay">
|
||||
<EuiModal onClose={onCloseCallback}>
|
||||
{isLoading === true && (
|
||||
<>
|
||||
<EuiModalBody>
|
||||
<EuiFlexGroup justifyContent="spaceAround">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLoadingSpinner size="xl" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalBody>
|
||||
</>
|
||||
)}
|
||||
{isLoading === false && (
|
||||
<>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.deleteJobCheckModal.modalTitle"
|
||||
defaultMessage="Checking space permissions"
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
|
||||
<EuiModalBody>{modalContent}</EuiModalBody>
|
||||
|
||||
<EuiModalFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
{!hasUntagged &&
|
||||
jobCheckRespSummary?.canTakeAnyAction &&
|
||||
jobCheckRespSummary?.canUntag &&
|
||||
jobCheckRespSummary?.canDelete && (
|
||||
<EuiButtonEmpty
|
||||
isLoading={isUntagging}
|
||||
color="primary"
|
||||
size="s"
|
||||
onClick={onUntagClick}
|
||||
>
|
||||
{shouldUnTagLabel}
|
||||
</EuiButtonEmpty>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
onClick={
|
||||
jobCheckRespSummary?.canTakeAnyAction &&
|
||||
jobCheckRespSummary?.canUntag &&
|
||||
!jobCheckRespSummary?.canDelete
|
||||
? onUntagClick
|
||||
: onClick
|
||||
}
|
||||
fill
|
||||
>
|
||||
{buttonContent}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiModalFooter>
|
||||
</>
|
||||
)}
|
||||
</EuiModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { DeleteJobCheckModal } from './delete_job_check_modal';
|
|
@ -23,6 +23,7 @@ export const DeleteActionModal: FC<DeleteAction> = ({
|
|||
deleteTargetIndex,
|
||||
deleteIndexPattern,
|
||||
indexPatternExists,
|
||||
isLoading,
|
||||
item,
|
||||
toggleDeleteIndex,
|
||||
toggleDeleteIndexPattern,
|
||||
|
@ -58,6 +59,7 @@ export const DeleteActionModal: FC<DeleteAction> = ({
|
|||
)}
|
||||
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
|
||||
buttonColor="danger"
|
||||
confirmButtonDisabled={isLoading}
|
||||
>
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem>
|
||||
|
|
|
@ -29,17 +29,23 @@ import {
|
|||
|
||||
import { deleteActionNameText, DeleteActionName } from './delete_action_name';
|
||||
|
||||
import { JobType } from '../../../../../../../common/types/saved_objects';
|
||||
|
||||
const DF_ANALYTICS_JOB_TYPE: JobType = 'data-frame-analytics';
|
||||
|
||||
type DataFrameAnalyticsListRowEssentials = Pick<DataFrameAnalyticsListRow, 'config' | 'stats'>;
|
||||
export type DeleteAction = ReturnType<typeof useDeleteAction>;
|
||||
export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
||||
const [item, setItem] = useState<DataFrameAnalyticsListRowEssentials>();
|
||||
|
||||
const [isModalVisible, setModalVisible] = useState(false);
|
||||
const [isModalVisible, setModalVisible] = useState<boolean>(false);
|
||||
const [isDeleteJobCheckModalVisible, setDeleteJobCheckModalVisible] = useState<boolean>(false);
|
||||
const [deleteItem, setDeleteItem] = useState(false);
|
||||
const [deleteTargetIndex, setDeleteTargetIndex] = useState<boolean>(true);
|
||||
const [deleteIndexPattern, setDeleteIndexPattern] = useState<boolean>(true);
|
||||
const [userCanDeleteIndex, setUserCanDeleteIndex] = useState<boolean>(false);
|
||||
const [indexPatternExists, setIndexPatternExists] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const { savedObjects } = useMlKibana().services;
|
||||
const savedObjectsClient = savedObjects.client;
|
||||
|
@ -65,8 +71,10 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
} else {
|
||||
setIndexPatternExists(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
} catch (e) {
|
||||
const error = extractErrorMessage(e);
|
||||
setIsLoading(false);
|
||||
|
||||
toastNotificationService.displayDangerToast(
|
||||
i18n.translate(
|
||||
|
@ -88,6 +96,7 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
}
|
||||
} catch (e) {
|
||||
const error = extractErrorMessage(e);
|
||||
setIsLoading(false);
|
||||
|
||||
toastNotificationService.displayDangerToast(
|
||||
i18n.translate(
|
||||
|
@ -103,15 +112,16 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
// Check if an index pattern exists corresponding to current DFA job
|
||||
// if pattern does exist, show it to user
|
||||
checkIndexPatternExists();
|
||||
|
||||
// Check if an user has permission to delete the index & index pattern
|
||||
checkUserIndexPermission();
|
||||
}, [isModalVisible]);
|
||||
|
||||
const closeModal = () => setModalVisible(false);
|
||||
const closeDeleteJobCheckModal = () => setDeleteJobCheckModalVisible(false);
|
||||
const deleteAndCloseModal = () => {
|
||||
setDeleteItem(true);
|
||||
setModalVisible(false);
|
||||
|
@ -138,6 +148,11 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const openDeleteJobCheckModal = (newItem: DataFrameAnalyticsListRowEssentials) => {
|
||||
setItem(newItem);
|
||||
setDeleteJobCheckModalVisible(true);
|
||||
};
|
||||
|
||||
const action: DataFrameAnalyticsListAction = useMemo(
|
||||
() => ({
|
||||
name: (i: DataFrameAnalyticsListRow) => (
|
||||
|
@ -151,7 +166,7 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
description: deleteActionNameText,
|
||||
icon: 'trash',
|
||||
type: 'icon',
|
||||
onClick: (i: DataFrameAnalyticsListRow) => openModal(i),
|
||||
onClick: (i: DataFrameAnalyticsListRow) => openDeleteJobCheckModal(i),
|
||||
'data-test-subj': 'mlAnalyticsJobDeleteButton',
|
||||
}),
|
||||
[]
|
||||
|
@ -159,15 +174,20 @@ export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => {
|
|||
|
||||
return {
|
||||
action,
|
||||
closeDeleteJobCheckModal,
|
||||
closeModal,
|
||||
deleteAndCloseModal,
|
||||
deleteTargetIndex,
|
||||
deleteIndexPattern,
|
||||
deleteItem,
|
||||
indexPatternExists,
|
||||
isDeleteJobCheckModalVisible,
|
||||
isModalVisible,
|
||||
isLoading,
|
||||
item,
|
||||
jobType: DF_ANALYTICS_JOB_TYPE,
|
||||
openModal,
|
||||
openDeleteJobCheckModal,
|
||||
toggleDeleteIndex,
|
||||
toggleDeleteIndexPattern,
|
||||
userCanDeleteIndex,
|
||||
|
|
|
@ -10,6 +10,7 @@ import { EuiTableActionsColumnType } from '@elastic/eui';
|
|||
|
||||
import { checkPermission } from '../../../../../capabilities/check_capabilities';
|
||||
|
||||
import { DeleteJobCheckModal } from '../../../../../components/delete_job_check_modal';
|
||||
import { useCloneAction } from '../action_clone';
|
||||
import { useDeleteAction, DeleteActionModal } from '../action_delete';
|
||||
import { isEditActionFlyoutVisible, useEditAction, EditActionFlyout } from '../action_edit';
|
||||
|
@ -19,6 +20,7 @@ import { useViewAction } from '../action_view';
|
|||
import { useMapAction } from '../action_map';
|
||||
|
||||
import { DataFrameAnalyticsListRow } from './common';
|
||||
import { useRefreshAnalyticsList } from '../../../../common/analytics';
|
||||
|
||||
export const useActions = (
|
||||
isManagementTable: boolean
|
||||
|
@ -38,6 +40,8 @@ export const useActions = (
|
|||
const startAction = useStartAction(canStartStopDataFrameAnalytics);
|
||||
const stopAction = useStopAction(canStartStopDataFrameAnalytics);
|
||||
|
||||
const { refresh } = useRefreshAnalyticsList();
|
||||
|
||||
let modals: JSX.Element | null = null;
|
||||
|
||||
const actions: EuiTableActionsColumnType<DataFrameAnalyticsListRow>['actions'] = [
|
||||
|
@ -52,6 +56,19 @@ export const useActions = (
|
|||
<>
|
||||
{startAction.isModalVisible && <StartActionModal {...startAction} />}
|
||||
{stopAction.isModalVisible && <StopActionModal {...stopAction} />}
|
||||
{deleteAction.isDeleteJobCheckModalVisible && deleteAction?.item?.config && (
|
||||
<DeleteJobCheckModal
|
||||
onCloseCallback={deleteAction.closeDeleteJobCheckModal}
|
||||
canDeleteCallback={() => {
|
||||
// Item will always be set by the time we open the delete modal
|
||||
deleteAction.openModal(deleteAction.item!);
|
||||
deleteAction.closeDeleteJobCheckModal();
|
||||
}}
|
||||
refreshJobsCallback={refresh}
|
||||
jobType={deleteAction.jobType}
|
||||
jobIds={[deleteAction.item.config.id]}
|
||||
/>
|
||||
)}
|
||||
{deleteAction.isModalVisible && <DeleteActionModal {...deleteAction} />}
|
||||
{isEditActionFlyoutVisible(editAction) && <EditActionFlyout {...editAction} />}
|
||||
</>
|
||||
|
|
|
@ -42,13 +42,14 @@ import {
|
|||
useDeleteAction,
|
||||
DeleteActionModal,
|
||||
} from '../../analytics_management/components/action_delete';
|
||||
import { DeleteJobCheckModal } from '../../../../components/delete_job_check_modal';
|
||||
|
||||
interface Props {
|
||||
analyticsId?: string;
|
||||
details: any;
|
||||
getNodeData: any;
|
||||
modelId?: string;
|
||||
updateElements: (nodeId: string, nodeLabel: string, destIndexNode?: string) => void;
|
||||
refreshJobsCallback: () => void;
|
||||
}
|
||||
|
||||
function getListItems(details: object): EuiDescriptionListProps['listItems'] {
|
||||
|
@ -74,20 +75,26 @@ function getListItems(details: object): EuiDescriptionListProps['listItems'] {
|
|||
});
|
||||
}
|
||||
|
||||
export const Controls: FC<Props> = ({
|
||||
analyticsId,
|
||||
details,
|
||||
getNodeData,
|
||||
modelId,
|
||||
updateElements,
|
||||
}) => {
|
||||
export const Controls: FC<Props> = React.memo(
|
||||
({ details, getNodeData, modelId, refreshJobsCallback, updateElements }) => {
|
||||
const [showFlyout, setShowFlyout] = useState<boolean>(false);
|
||||
const [selectedNode, setSelectedNode] = useState<cytoscape.NodeSingular | undefined>();
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const [isPopoverOpen, setPopover] = useState<boolean>(false);
|
||||
const [didUntag, setDidUntag] = useState<boolean>(false);
|
||||
|
||||
const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics');
|
||||
const deleteAction = useDeleteAction(canDeleteDataFrameAnalytics);
|
||||
const { deleteItem, deleteTargetIndex, isModalVisible, openModal } = deleteAction;
|
||||
const {
|
||||
closeDeleteJobCheckModal,
|
||||
deleteItem,
|
||||
deleteTargetIndex,
|
||||
isModalVisible,
|
||||
isDeleteJobCheckModalVisible,
|
||||
item,
|
||||
jobType,
|
||||
openModal,
|
||||
openDeleteJobCheckModal,
|
||||
} = deleteAction;
|
||||
const { toasts } = useNotifications();
|
||||
const mlUrlGenerator = useMlUrlGenerator();
|
||||
const navigateToPath = useNavigateToPath();
|
||||
|
@ -161,9 +168,9 @@ export const Controls: FC<Props> = ({
|
|||
|
||||
useEffect(
|
||||
function updateElementsOnClose() {
|
||||
if (isModalVisible === false && deleteItem === true) {
|
||||
if ((isModalVisible === false && deleteItem === true) || didUntag === true) {
|
||||
let destIndexNode;
|
||||
if (deleteTargetIndex === true) {
|
||||
if (deleteTargetIndex === true || didUntag === true) {
|
||||
const jobDetails = details[nodeId];
|
||||
const destIndex = jobDetails.dest.index;
|
||||
destIndexNode = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`;
|
||||
|
@ -172,7 +179,7 @@ export const Controls: FC<Props> = ({
|
|||
setShowFlyout(false);
|
||||
}
|
||||
},
|
||||
[isModalVisible, deleteItem]
|
||||
[isModalVisible, deleteItem, didUntag]
|
||||
);
|
||||
|
||||
if (showFlyout === false) {
|
||||
|
@ -195,7 +202,7 @@ export const Controls: FC<Props> = ({
|
|||
key={`${nodeId}-delete`}
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
openModal({ config: details[nodeId], stats: details[nodeId]?.stats });
|
||||
openDeleteJobCheckModal({ config: details[nodeId], stats: details[nodeId]?.stats });
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
|
@ -225,8 +232,7 @@ export const Controls: FC<Props> = ({
|
|||
</EuiContextMenuItem>,
|
||||
]
|
||||
: []),
|
||||
...(analyticsId !== nodeLabel &&
|
||||
modelId !== nodeLabel &&
|
||||
...(modelId !== nodeLabel &&
|
||||
(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX)
|
||||
? [
|
||||
<EuiContextMenuItem
|
||||
|
@ -299,7 +305,22 @@ export const Controls: FC<Props> = ({
|
|||
)}
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
{isDeleteJobCheckModalVisible && item && (
|
||||
<DeleteJobCheckModal
|
||||
jobType={jobType}
|
||||
jobIds={[item.config.id]}
|
||||
onCloseCallback={closeDeleteJobCheckModal}
|
||||
canDeleteCallback={() => {
|
||||
// Item will always be set by the time we open the delete modal
|
||||
openModal(deleteAction.item!);
|
||||
closeDeleteJobCheckModal();
|
||||
}}
|
||||
refreshJobsCallback={refreshJobsCallback}
|
||||
setDidUntag={setDidUntag}
|
||||
/>
|
||||
)}
|
||||
{isModalVisible && <DeleteActionModal {...deleteAction} />}
|
||||
</EuiPortal>
|
||||
);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -127,6 +127,8 @@ export const JobMap: FC<Props> = ({ analyticsId, modelId }) => {
|
|||
|
||||
const { ref, width, height } = useRefDimensions();
|
||||
|
||||
const refreshCallback = () => fetchAndSetElementsWrapper({ analyticsId, modelId });
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
|
@ -147,7 +149,7 @@ export const JobMap: FC<Props> = ({ analyticsId, modelId }) => {
|
|||
<EuiButtonEmpty
|
||||
size="xs"
|
||||
data-test-subj={`mlAnalyticsRefreshMapButton${isLoading ? ' loading' : ' loaded'}`}
|
||||
onClick={() => fetchAndSetElementsWrapper({ analyticsId, modelId })}
|
||||
onClick={refreshCallback}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<FormattedMessage
|
||||
|
@ -184,9 +186,9 @@ export const JobMap: FC<Props> = ({ analyticsId, modelId }) => {
|
|||
<Controls
|
||||
details={nodeDetails}
|
||||
getNodeData={fetchAndSetElementsWrapper}
|
||||
analyticsId={analyticsId}
|
||||
modelId={modelId}
|
||||
updateElements={updateElements}
|
||||
refreshJobsCallback={refreshCallback}
|
||||
/>
|
||||
</Cytoscape>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* 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 React, { FC, useState, useEffect } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiSpacer,
|
||||
EuiModal,
|
||||
EuiOverlayMask,
|
||||
EuiModalHeader,
|
||||
EuiModalHeaderTitle,
|
||||
EuiModalBody,
|
||||
EuiModalFooter,
|
||||
EuiButtonEmpty,
|
||||
EuiButton,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { deleteJobs } from '../utils';
|
||||
import { DELETING_JOBS_REFRESH_INTERVAL_MS } from '../../../../../../common/constants/jobs_list';
|
||||
import { DeleteJobCheckModal } from '../../../../components/delete_job_check_modal';
|
||||
|
||||
type ShowFunc = (jobs: Array<{ id: string }>) => void;
|
||||
|
||||
interface Props {
|
||||
setShowFunction(showFunc: ShowFunc): void;
|
||||
unsetShowFunction(): void;
|
||||
refreshJobs(): void;
|
||||
}
|
||||
|
||||
export const DeleteJobModal: FC<Props> = ({ setShowFunction, unsetShowFunction, refreshJobs }) => {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [jobIds, setJobIds] = useState<string[]>([]);
|
||||
const [canDelete, setCanDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof setShowFunction === 'function') {
|
||||
setShowFunction(showModal);
|
||||
}
|
||||
return () => {
|
||||
if (typeof unsetShowFunction === 'function') {
|
||||
unsetShowFunction();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showModal(jobs: any[]) {
|
||||
setJobIds(jobs.map(({ id }) => id));
|
||||
setModalVisible(true);
|
||||
setDeleting(false);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalVisible(false);
|
||||
setCanDelete(false);
|
||||
}
|
||||
|
||||
function deleteJob() {
|
||||
setDeleting(true);
|
||||
deleteJobs(jobIds.map((id) => ({ id })));
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
refreshJobs();
|
||||
}, DELETING_JOBS_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (modalVisible === false || jobIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (canDelete) {
|
||||
return (
|
||||
<EuiOverlayMask>
|
||||
<EuiModal data-test-subj="mlDeleteJobConfirmModal" onClose={closeModal}>
|
||||
<EuiModalHeader>
|
||||
<EuiModalHeaderTitle>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobsList.deleteJobModal.deleteJobsTitle"
|
||||
defaultMessage="Delete {jobsCount, plural, one {{jobId}} other {# jobs}}?"
|
||||
values={{
|
||||
jobsCount: jobIds.length,
|
||||
jobId: jobIds[0],
|
||||
}}
|
||||
/>
|
||||
</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<p>
|
||||
{deleting === true ? (
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobsList.deleteJobModal.deletingJobsStatusLabel"
|
||||
defaultMessage="Deleting jobs"
|
||||
/>
|
||||
<EuiSpacer />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<EuiLoadingSpinner size="l" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobsList.deleteJobModal.deleteMultipleJobsDescription"
|
||||
defaultMessage="Deleting {jobsCount, plural, one {a job} other {multiple jobs}} can be time consuming.
|
||||
{jobsCount, plural, one {It} other {They}} will be deleted in the background
|
||||
and may not disappear from the jobs list instantly."
|
||||
values={{
|
||||
jobsCount: jobIds.length,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</EuiModalBody>
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<EuiModalFooter>
|
||||
<EuiButtonEmpty onClick={closeModal} disabled={deleting}>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobsList.deleteJobModal.cancelButtonLabel"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
|
||||
<EuiButton
|
||||
onClick={deleteJob}
|
||||
fill
|
||||
disabled={deleting}
|
||||
color="danger"
|
||||
data-test-subj="mlDeleteJobConfirmModalButton"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.ml.jobsList.deleteJobModal.deleteButtonLabel"
|
||||
defaultMessage="Delete"
|
||||
/>
|
||||
</EuiButton>
|
||||
</EuiModalFooter>
|
||||
</>
|
||||
</EuiModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<DeleteJobCheckModal
|
||||
jobIds={jobIds}
|
||||
jobType="anomaly-detector"
|
||||
canDeleteCallback={() => {
|
||||
setCanDelete(true);
|
||||
}}
|
||||
onCloseCallback={closeModal}
|
||||
refreshJobsCallback={refreshJobs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
7
x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts
vendored
Normal file
7
x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export function deleteJobs(jobs: Array<{ id: string }>, callback?: () => void): Promise<void>;
|
|
@ -11,6 +11,7 @@ import { HttpService } from '../http_service';
|
|||
import { basePath } from './index';
|
||||
import {
|
||||
JobType,
|
||||
CanDeleteJobResponse,
|
||||
SyncSavedObjectResponse,
|
||||
SavedObjectResult,
|
||||
JobsSpacesResponse,
|
||||
|
@ -39,7 +40,14 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({
|
|||
body,
|
||||
});
|
||||
},
|
||||
|
||||
removeJobFromCurrentSpace(jobType: JobType, jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobType, jobIds });
|
||||
return httpService.http<SavedObjectResult>({
|
||||
path: `${basePath()}/saved_objects/remove_job_from_current_space`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
syncSavedObjects(simulate: boolean = false) {
|
||||
return httpService.http<SyncSavedObjectResponse>({
|
||||
path: `${basePath()}/saved_objects/sync`,
|
||||
|
@ -47,4 +55,13 @@ export const savedObjectsApiProvider = (httpService: HttpService) => ({
|
|||
query: { simulate },
|
||||
});
|
||||
},
|
||||
|
||||
canDeleteJob(jobType: JobType, jobIds: string[]) {
|
||||
const body = JSON.stringify({ jobIds });
|
||||
return httpService.http<CanDeleteJobResponse>({
|
||||
path: `${basePath()}/saved_objects/can_delete_job/${jobType}`,
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -52,5 +52,16 @@ export function spacesUtilsProvider(
|
|||
return spaces.filter((s) => s.disabledFeatures.includes(PLUGIN_ID) === false).map((s) => s.id);
|
||||
}
|
||||
|
||||
return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds };
|
||||
async function getCurrentSpaceId(): Promise<string | null> {
|
||||
if (getSpacesPlugin === undefined) {
|
||||
// if spaces is disabled force isMlEnabledInSpace to be true
|
||||
return null;
|
||||
}
|
||||
const space = await (await getSpacesPlugin()).spacesService.getActiveSpace(
|
||||
request instanceof KibanaRequest ? request : KibanaRequest.from(request)
|
||||
);
|
||||
return space.id;
|
||||
}
|
||||
|
||||
return { isMlEnabledInSpace, getAllSpaces, getAllSpaceIds, getMlSpaceIds, getCurrentSpaceId };
|
||||
}
|
||||
|
|
|
@ -149,6 +149,7 @@
|
|||
"InitializeJobSavedObjects",
|
||||
"AssignJobsToSpaces",
|
||||
"RemoveJobsFromSpaces",
|
||||
"RemoveJobsFromCurrentSpace",
|
||||
"JobsSpaces",
|
||||
"DeleteJobCheck",
|
||||
|
||||
|
|
|
@ -7,8 +7,15 @@
|
|||
import { wrapError } from '../client/error_wrapper';
|
||||
import { RouteInitialization, SavedObjectsRouteDeps } from '../types';
|
||||
import { checksFactory, syncSavedObjectsFactory } from '../saved_objects';
|
||||
import { jobsAndSpaces, syncJobObjects, jobTypeSchema } from './schemas/saved_objects';
|
||||
import {
|
||||
jobsAndSpaces,
|
||||
jobsAndCurrentSpace,
|
||||
syncJobObjects,
|
||||
jobTypeSchema,
|
||||
} from './schemas/saved_objects';
|
||||
import { jobIdsSchema } from './schemas/job_service_schema';
|
||||
import { spacesUtilsProvider } from '../lib/spaces_utils';
|
||||
import { JobType } from '../../common/types/saved_objects';
|
||||
|
||||
/**
|
||||
* Routes for job saved object management
|
||||
|
@ -184,6 +191,55 @@ export function savedObjectsRoutes(
|
|||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @apiGroup JobSavedObjects
|
||||
*
|
||||
* @api {post} /api/ml/saved_objects/remove_job_from_current_space Remove jobs from the current space
|
||||
* @apiName RemoveJobsFromCurrentSpace
|
||||
* @apiDescription Remove a list of jobs from the current space
|
||||
*
|
||||
* @apiSchema (body) jobsAndCurrentSpace
|
||||
*/
|
||||
router.post(
|
||||
{
|
||||
path: '/api/ml/saved_objects/remove_job_from_current_space',
|
||||
validate: {
|
||||
body: jobsAndCurrentSpace,
|
||||
},
|
||||
options: {
|
||||
tags: ['access:ml:canCreateJob', 'access:ml:canCreateDataFrameAnalytics'],
|
||||
},
|
||||
},
|
||||
routeGuard.fullLicenseAPIGuard(async ({ request, response, jobSavedObjectService }) => {
|
||||
try {
|
||||
const { jobType, jobIds }: { jobType: JobType; jobIds: string[] } = request.body;
|
||||
const { getCurrentSpaceId } = spacesUtilsProvider(getSpaces, request);
|
||||
|
||||
const currentSpaceId = await getCurrentSpaceId();
|
||||
if (currentSpaceId === null) {
|
||||
return response.ok({
|
||||
body: jobIds.map((id) => ({
|
||||
[id]: {
|
||||
success: false,
|
||||
error: 'Cannot remove current space. Spaces plugin is disabled.',
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const body = await jobSavedObjectService.removeJobsFromSpaces(jobType, jobIds, [
|
||||
currentSpaceId,
|
||||
]);
|
||||
|
||||
return response.ok({
|
||||
body,
|
||||
});
|
||||
} catch (e) {
|
||||
return response.customError(wrapError(e));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @apiGroup JobSavedObjects
|
||||
*
|
||||
|
|
|
@ -12,6 +12,11 @@ export const jobsAndSpaces = schema.object({
|
|||
spaces: schema.arrayOf(schema.string()),
|
||||
});
|
||||
|
||||
export const jobsAndCurrentSpace = schema.object({
|
||||
jobType: schema.string(),
|
||||
jobIds: schema.arrayOf(schema.string()),
|
||||
});
|
||||
|
||||
export const syncJobObjects = schema.object({ simulate: schema.maybe(schema.boolean()) });
|
||||
|
||||
export const jobTypeSchema = schema.object({
|
||||
|
|
|
@ -316,7 +316,7 @@ export function MachineLearningJobTableProvider({ getService }: FtrProviderConte
|
|||
}
|
||||
|
||||
public async confirmDeleteJobModal() {
|
||||
await testSubjects.click('mlDeleteJobConfirmModal > confirmModalConfirmButton');
|
||||
await testSubjects.click('mlDeleteJobConfirmModal > mlDeleteJobConfirmModalButton');
|
||||
await testSubjects.missingOrFail('mlDeleteJobConfirmModal', { timeout: 30 * 1000 });
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue