From 43e20112b1bdc9bf90bebd41244a0ef0885616d4 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 15 Dec 2020 12:44:03 -0500 Subject: [PATCH] [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 --- .../plugins/ml/common/types/saved_objects.ts | 7 + .../delete_job_check_modal.tsx | 297 ++++++++++++ .../delete_job_check_modal/index.ts | 7 + .../action_delete/delete_action_modal.tsx | 2 + .../action_delete/use_delete_action.tsx | 26 +- .../components/analytics_list/use_actions.tsx | 17 + .../pages/job_map/components/controls.tsx | 457 +++++++++--------- .../pages/job_map/job_map.tsx | 6 +- .../delete_job_modal/delete_job_modal.tsx | 162 +++++++ ...lete_job_modal.js => delete_job_modal_.js} | 0 .../delete_job_modal/{index.js => index.ts} | 0 .../jobs/jobs_list/components/utils.d.ts | 7 + .../services/ml_api_service/saved_objects.ts | 19 +- x-pack/plugins/ml/server/lib/spaces_utils.ts | 13 +- x-pack/plugins/ml/server/routes/apidoc.json | 1 + .../plugins/ml/server/routes/saved_objects.ts | 58 ++- .../ml/server/routes/schemas/saved_objects.ts | 5 + .../test/functional/services/ml/job_table.ts | 2 +- 18 files changed, 859 insertions(+), 227 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx create mode 100644 x-pack/plugins/ml/public/application/components/delete_job_check_modal/index.ts create mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx rename x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/{delete_job_modal.js => delete_job_modal_.js} (100%) rename x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/{index.js => index.ts} (100%) create mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts diff --git a/x-pack/plugins/ml/common/types/saved_objects.ts b/x-pack/plugins/ml/common/types/saved_objects.ts index aa3220747e84..1907078fd975 100644 --- a/x-pack/plugins/ml/common/types/saved_objects.ts +++ b/x-pack/plugins/ml/common/types/saved_objects.ts @@ -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[] }; }; diff --git a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx new file mode 100644 index 000000000000..151946ab31fd --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx @@ -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: ( + + ), + modalText: ( + + + + ), + }; + } + + const noActionContent: ModalContentReturnType = { + buttonText: ( + + ), + modalText: ( + + + + ), + }; + + if (canDelete) { + return { + buttonText: ( + + ), + modalText: ( + + + + ), + }; + } else if (canUntag) { + return { + buttonText: ( + + ), + modalText: ( + + + + ), + }; + } else { + return noActionContent; + } +} + +interface Props { + canDeleteCallback: () => void; + onCloseCallback: () => void; + refreshJobsCallback?: () => void; + jobType: JobType; + jobIds: string[]; + setDidUntag?: React.Dispatch>; +} + +export const DeleteJobCheckModal: FC = ({ + canDeleteCallback, + onCloseCallback, + refreshJobsCallback, + jobType, + jobIds, + setDidUntag, +}) => { + const [buttonContent, setButtonContent] = useState(); + const [modalContent, setModalContent] = useState(); + const [hasUntagged, setHasUntagged] = useState(false); + const [isUntagging, setIsUntagging] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [jobCheckRespSummary, setJobCheckRespSummary] = useState(); + + 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 ( + + + {isLoading === true && ( + <> + + + + + + + + + )} + {isLoading === false && ( + <> + + + + + + + {modalContent} + + + + + {!hasUntagged && + jobCheckRespSummary?.canTakeAnyAction && + jobCheckRespSummary?.canUntag && + jobCheckRespSummary?.canDelete && ( + + {shouldUnTagLabel} + + )} + + + + {buttonContent} + + + + + + )} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/index.ts b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/index.ts new file mode 100644 index 000000000000..8fd9dc28ed8d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/index.ts @@ -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'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx index 5db8446dec32..07e2e9613846 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx @@ -23,6 +23,7 @@ export const DeleteActionModal: FC = ({ deleteTargetIndex, deleteIndexPattern, indexPatternExists, + isLoading, item, toggleDeleteIndex, toggleDeleteIndexPattern, @@ -58,6 +59,7 @@ export const DeleteActionModal: FC = ({ )} defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} buttonColor="danger" + confirmButtonDisabled={isLoading} > diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.tsx index 5627532d3ed2..520d410fa79f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.tsx @@ -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; export type DeleteAction = ReturnType; export const useDeleteAction = (canDeleteDataFrameAnalytics: boolean) => { const [item, setItem] = useState(); - const [isModalVisible, setModalVisible] = useState(false); + const [isModalVisible, setModalVisible] = useState(false); + const [isDeleteJobCheckModalVisible, setDeleteJobCheckModalVisible] = useState(false); const [deleteItem, setDeleteItem] = useState(false); const [deleteTargetIndex, setDeleteTargetIndex] = useState(true); const [deleteIndexPattern, setDeleteIndexPattern] = useState(true); const [userCanDeleteIndex, setUserCanDeleteIndex] = useState(false); const [indexPatternExists, setIndexPatternExists] = useState(false); + const [isLoading, setIsLoading] = useState(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, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx index 74b367cc7ab1..5351c1714755 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_actions.tsx @@ -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['actions'] = [ @@ -52,6 +56,19 @@ export const useActions = ( <> {startAction.isModalVisible && } {stopAction.isModalVisible && } + {deleteAction.isDeleteJobCheckModalVisible && deleteAction?.item?.config && ( + { + // 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 && } {isEditActionFlyoutVisible(editAction) && } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx index dd549ad8113f..d307df75cb32 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/components/controls.tsx @@ -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,232 +75,252 @@ function getListItems(details: object): EuiDescriptionListProps['listItems'] { }); } -export const Controls: FC = ({ - analyticsId, - details, - getNodeData, - modelId, - updateElements, -}) => { - const [showFlyout, setShowFlyout] = useState(false); - const [selectedNode, setSelectedNode] = useState(); - const [isPopoverOpen, setPopover] = useState(false); +export const Controls: FC = React.memo( + ({ details, getNodeData, modelId, refreshJobsCallback, updateElements }) => { + const [showFlyout, setShowFlyout] = useState(false); + const [selectedNode, setSelectedNode] = useState(); + const [isPopoverOpen, setPopover] = useState(false); + const [didUntag, setDidUntag] = useState(false); - const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); - const deleteAction = useDeleteAction(canDeleteDataFrameAnalytics); - const { deleteItem, deleteTargetIndex, isModalVisible, openModal } = deleteAction; - const { toasts } = useNotifications(); - const mlUrlGenerator = useMlUrlGenerator(); - const navigateToPath = useNavigateToPath(); - const navigateToWizardWithClonedJob = useNavigateToWizardWithClonedJob(); + const canDeleteDataFrameAnalytics: boolean = checkPermission('canDeleteDataFrameAnalytics'); + const deleteAction = useDeleteAction(canDeleteDataFrameAnalytics); + const { + closeDeleteJobCheckModal, + deleteItem, + deleteTargetIndex, + isModalVisible, + isDeleteJobCheckModalVisible, + item, + jobType, + openModal, + openDeleteJobCheckModal, + } = deleteAction; + const { toasts } = useNotifications(); + const mlUrlGenerator = useMlUrlGenerator(); + const navigateToPath = useNavigateToPath(); + const navigateToWizardWithClonedJob = useNavigateToWizardWithClonedJob(); - const cy = useContext(CytoscapeContext); - const deselect = useCallback(() => { - if (cy) { - cy.elements().unselect(); - } - setShowFlyout(false); - setSelectedNode(undefined); - }, [cy, setSelectedNode]); - - const nodeId = selectedNode?.data('id'); - const nodeLabel = selectedNode?.data('label'); - const nodeType = selectedNode?.data('type'); - - const onCreateJobClick = useCallback(async () => { - const indexId = getIndexPatternIdFromName(nodeLabel); - - if (indexId) { - const path = await mlUrlGenerator.createUrl({ - page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, - pageState: { index: indexId }, - }); - - await navigateToPath(path); - } else { - toasts.addDanger( - i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.indexPatternMissingMessage', { - defaultMessage: - 'To create a job from this index please create an index pattern for {indexTitle}.', - values: { indexTitle: nodeLabel }, - }) - ); - } - }, [nodeLabel]); - - const onCloneJobClick = useCallback(async () => { - navigateToWizardWithClonedJob({ config: details[nodeId], stats: details[nodeId]?.stats }); - }, [nodeId]); - - const onActionsButtonClick = () => { - setPopover(!isPopoverOpen); - }; - - const closePopover = () => { - setPopover(false); - }; - - // Set up Cytoscape event handlers - useEffect(() => { - const selectHandler: cytoscape.EventHandler = (event) => { - setSelectedNode(event.target); - setShowFlyout(true); - }; - - if (cy) { - cy.on('select', 'node', selectHandler); - cy.on('unselect', 'node', deselect); - } - - return () => { + const cy = useContext(CytoscapeContext); + const deselect = useCallback(() => { if (cy) { - cy.removeListener('select', 'node', selectHandler); - cy.removeListener('unselect', 'node', deselect); + cy.elements().unselect(); } + setShowFlyout(false); + setSelectedNode(undefined); + }, [cy, setSelectedNode]); + + const nodeId = selectedNode?.data('id'); + const nodeLabel = selectedNode?.data('label'); + const nodeType = selectedNode?.data('type'); + + const onCreateJobClick = useCallback(async () => { + const indexId = getIndexPatternIdFromName(nodeLabel); + + if (indexId) { + const path = await mlUrlGenerator.createUrl({ + page: ML_PAGES.DATA_FRAME_ANALYTICS_CREATE_JOB, + pageState: { index: indexId }, + }); + + await navigateToPath(path); + } else { + toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analyticsMap.flyout.indexPatternMissingMessage', { + defaultMessage: + 'To create a job from this index please create an index pattern for {indexTitle}.', + values: { indexTitle: nodeLabel }, + }) + ); + } + }, [nodeLabel]); + + const onCloneJobClick = useCallback(async () => { + navigateToWizardWithClonedJob({ config: details[nodeId], stats: details[nodeId]?.stats }); + }, [nodeId]); + + const onActionsButtonClick = () => { + setPopover(!isPopoverOpen); }; - }, [cy, deselect]); - useEffect( - function updateElementsOnClose() { - if (isModalVisible === false && deleteItem === true) { - let destIndexNode; - if (deleteTargetIndex === true) { - const jobDetails = details[nodeId]; - const destIndex = jobDetails.dest.index; - destIndexNode = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`; - } - updateElements(nodeId, nodeLabel, destIndexNode); - setShowFlyout(false); + const closePopover = () => { + setPopover(false); + }; + + // Set up Cytoscape event handlers + useEffect(() => { + const selectHandler: cytoscape.EventHandler = (event) => { + setSelectedNode(event.target); + setShowFlyout(true); + }; + + if (cy) { + cy.on('select', 'node', selectHandler); + cy.on('unselect', 'node', deselect); } - }, - [isModalVisible, deleteItem] - ); - if (showFlyout === false) { - return null; - } + return () => { + if (cy) { + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', deselect); + } + }; + }, [cy, deselect]); - const button = ( - - - - ); + useEffect( + function updateElementsOnClose() { + if ((isModalVisible === false && deleteItem === true) || didUntag === true) { + let destIndexNode; + if (deleteTargetIndex === true || didUntag === true) { + const jobDetails = details[nodeId]; + const destIndex = jobDetails.dest.index; + destIndexNode = `${destIndex}-${JOB_MAP_NODE_TYPES.INDEX}`; + } + updateElements(nodeId, nodeLabel, destIndexNode); + setShowFlyout(false); + } + }, + [isModalVisible, deleteItem, didUntag] + ); - const items = [ - ...(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS - ? [ - { - openModal({ config: details[nodeId], stats: details[nodeId]?.stats }); - }} - > - - , - - - , - ] - : []), - ...(nodeType === JOB_MAP_NODE_TYPES.INDEX - ? [ - - - , - ] - : []), - ...(analyticsId !== nodeLabel && - modelId !== nodeLabel && - (nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX) - ? [ - { - getNodeData({ id: nodeLabel, type: nodeType }); - setShowFlyout(false); - setPopover(false); - }} - > - - , - ] - : []), - ]; + if (showFlyout === false) { + return null; + } - return ( - - setShowFlyout(false)} - data-test-subj="mlAnalyticsJobMapFlyout" - > - - - - -

- -

-
-
-
-
- - - - - - - - - {nodeType !== JOB_MAP_NODE_TYPES.TRAINED_MODEL && ( - + + + ); + + const items = [ + ...(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS + ? [ + { + openDeleteJobCheckModal({ config: details[nodeId], stats: details[nodeId]?.stats }); + }} > - - - )} - -
- {isModalVisible && } -
- ); -}; + + , + + + , + ] + : []), + ...(nodeType === JOB_MAP_NODE_TYPES.INDEX + ? [ + + + , + ] + : []), + ...(modelId !== nodeLabel && + (nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX) + ? [ + { + getNodeData({ id: nodeLabel, type: nodeType }); + setShowFlyout(false); + setPopover(false); + }} + > + + , + ] + : []), + ]; + + return ( + + setShowFlyout(false)} + data-test-subj="mlAnalyticsJobMapFlyout" + > + + + + +

+ +

+
+
+
+
+ + + + + + + + + {nodeType !== JOB_MAP_NODE_TYPES.TRAINED_MODEL && ( + + + + )} + +
+ {isDeleteJobCheckModalVisible && item && ( + { + // Item will always be set by the time we open the delete modal + openModal(deleteAction.item!); + closeDeleteJobCheckModal(); + }} + refreshJobsCallback={refreshJobsCallback} + setDidUntag={setDidUntag} + /> + )} + {isModalVisible && } +
+ ); + } +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx index 887d6a03757c..7ca56617ab81 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/job_map.tsx @@ -127,6 +127,8 @@ export const JobMap: FC = ({ analyticsId, modelId }) => { const { ref, width, height } = useRefDimensions(); + const refreshCallback = () => fetchAndSetElementsWrapper({ analyticsId, modelId }); + return ( <> @@ -147,7 +149,7 @@ export const JobMap: FC = ({ analyticsId, modelId }) => { fetchAndSetElementsWrapper({ analyticsId, modelId })} + onClick={refreshCallback} isLoading={isLoading} > = ({ analyticsId, modelId }) => { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx new file mode 100644 index 000000000000..90ae44eb85e5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx @@ -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 = ({ setShowFunction, unsetShowFunction, refreshJobs }) => { + const [deleting, setDeleting] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [jobIds, setJobIds] = useState([]); + 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 ( + + + + + + + + +

+ {deleting === true ? ( +

+ + +
+ +
+
+ ) : ( + <> + + + )} +

+
+ <> + + + + + + + + + + + +
+
+ ); + } else { + return ( + <> + { + setCanDelete(true); + }} + onCloseCallback={closeModal} + refreshJobsCallback={refreshJobs} + /> + + ); + } +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal_.js similarity index 100% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.js rename to x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal_.js diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/index.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/index.js rename to x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/index.ts diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts new file mode 100644 index 000000000000..46aa762458a2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts @@ -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; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts index e821fa3da4d6..728a258afee2 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/saved_objects.ts @@ -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({ + path: `${basePath()}/saved_objects/remove_job_from_current_space`, + method: 'POST', + body, + }); + }, syncSavedObjects(simulate: boolean = false) { return httpService.http({ 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({ + path: `${basePath()}/saved_objects/can_delete_job/${jobType}`, + method: 'POST', + body, + }); + }, }); diff --git a/x-pack/plugins/ml/server/lib/spaces_utils.ts b/x-pack/plugins/ml/server/lib/spaces_utils.ts index ecff3b8124cf..2ab1f52ac158 100644 --- a/x-pack/plugins/ml/server/lib/spaces_utils.ts +++ b/x-pack/plugins/ml/server/lib/spaces_utils.ts @@ -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 { + 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 }; } diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 85df7228fe92..bf002907b1a4 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -149,6 +149,7 @@ "InitializeJobSavedObjects", "AssignJobsToSpaces", "RemoveJobsFromSpaces", + "RemoveJobsFromCurrentSpace", "JobsSpaces", "DeleteJobCheck", diff --git a/x-pack/plugins/ml/server/routes/saved_objects.ts b/x-pack/plugins/ml/server/routes/saved_objects.ts index 57c6084d9971..29f9b218ea17 100644 --- a/x-pack/plugins/ml/server/routes/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/saved_objects.ts @@ -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 * diff --git a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts index c2d091bd1605..147398694f19 100644 --- a/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts +++ b/x-pack/plugins/ml/server/routes/schemas/saved_objects.ts @@ -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({ diff --git a/x-pack/test/functional/services/ml/job_table.ts b/x-pack/test/functional/services/ml/job_table.ts index 54c03c876af8..c3b998d61fb3 100644 --- a/x-pack/test/functional/services/ml/job_table.ts +++ b/x-pack/test/functional/services/ml/job_table.ts @@ -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 }); }