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 });
}