[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:
Melissa Alvarez 2020-12-15 12:44:03 -05:00 committed by GitHub
parent 9b71c94ff1
commit 43e20112b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 859 additions and 227 deletions

View file

@ -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[] };
};

View file

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

View 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 { DeleteJobCheckModal } from './delete_job_check_modal';

View file

@ -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>

View file

@ -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,

View file

@ -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} />}
</>

View file

@ -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<Props> = ({
analyticsId,
details,
getNodeData,
modelId,
updateElements,
}) => {
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const [selectedNode, setSelectedNode] = useState<cytoscape.NodeSingular | undefined>();
const [isPopoverOpen, setPopover] = useState(false);
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<boolean>(false);
const [didUntag, setDidUntag] = useState<boolean>(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 = (
<EuiButton size="s" iconType="arrowDown" iconSide="right" onClick={onActionsButtonClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.nodeActionsButton"
defaultMessage="Node actions"
/>
</EuiButton>
);
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
? [
<EuiContextMenuItem
key={`${nodeId}-delete`}
icon="trash"
onClick={() => {
openModal({ config: details[nodeId], stats: details[nodeId]?.stats });
}}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.deleteJobButton"
defaultMessage="Delete job"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key={`${nodeId}-clone`} icon="copy" onClick={onCloneJobClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.cloneJobButton"
defaultMessage="Clone job"
/>
</EuiContextMenuItem>,
]
: []),
...(nodeType === JOB_MAP_NODE_TYPES.INDEX
? [
<EuiContextMenuItem
key={`${nodeId}-create`}
icon="plusInCircle"
onClick={onCreateJobClick}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.createJobButton"
defaultMessage="Create job from this index"
/>
</EuiContextMenuItem>,
]
: []),
...(analyticsId !== nodeLabel &&
modelId !== nodeLabel &&
(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX)
? [
<EuiContextMenuItem
key={`${nodeId}-fetch-related`}
icon="branch"
onClick={() => {
getNodeData({ id: nodeLabel, type: nodeType });
setShowFlyout(false);
setPopover(false);
}}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.fetchRelatedNodesButton"
defaultMessage="Fetch related nodes"
/>
</EuiContextMenuItem>,
]
: []),
];
if (showFlyout === false) {
return null;
}
return (
<EuiPortal>
<EuiFlyout
ownFocus
size="m"
onClose={() => setShowFlyout(false)}
data-test-subj="mlAnalyticsJobMapFlyout"
>
<EuiFlyoutHeader>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h3 data-test-subj="mlDataFrameAnalyticsNodeDetailsTitle">
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyoutHeaderTitle"
defaultMessage="Details for {type} {id}"
values={{ id: nodeLabel, type: nodeType }}
/>
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiDescriptionList
compressed
type="column"
listItems={
nodeType === 'index-pattern'
? getListItems(details[nodeId][nodeLabel])
: getListItems(details[nodeId])
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
{nodeType !== JOB_MAP_NODE_TYPES.TRAINED_MODEL && (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="s"
anchorPosition="downLeft"
const button = (
<EuiButton size="s" iconType="arrowDown" iconSide="right" onClick={onActionsButtonClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.nodeActionsButton"
defaultMessage="Node actions"
/>
</EuiButton>
);
const items = [
...(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS
? [
<EuiContextMenuItem
key={`${nodeId}-delete`}
icon="trash"
onClick={() => {
openDeleteJobCheckModal({ config: details[nodeId], stats: details[nodeId]?.stats });
}}
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
)}
</EuiFlyoutFooter>
</EuiFlyout>
{isModalVisible && <DeleteActionModal {...deleteAction} />}
</EuiPortal>
);
};
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.deleteJobButton"
defaultMessage="Delete job"
/>
</EuiContextMenuItem>,
<EuiContextMenuItem key={`${nodeId}-clone`} icon="copy" onClick={onCloneJobClick}>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.cloneJobButton"
defaultMessage="Clone job"
/>
</EuiContextMenuItem>,
]
: []),
...(nodeType === JOB_MAP_NODE_TYPES.INDEX
? [
<EuiContextMenuItem
key={`${nodeId}-create`}
icon="plusInCircle"
onClick={onCreateJobClick}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.createJobButton"
defaultMessage="Create job from this index"
/>
</EuiContextMenuItem>,
]
: []),
...(modelId !== nodeLabel &&
(nodeType === JOB_MAP_NODE_TYPES.ANALYTICS || nodeType === JOB_MAP_NODE_TYPES.INDEX)
? [
<EuiContextMenuItem
key={`${nodeId}-fetch-related`}
icon="branch"
onClick={() => {
getNodeData({ id: nodeLabel, type: nodeType });
setShowFlyout(false);
setPopover(false);
}}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyout.fetchRelatedNodesButton"
defaultMessage="Fetch related nodes"
/>
</EuiContextMenuItem>,
]
: []),
];
return (
<EuiPortal>
<EuiFlyout
ownFocus
size="m"
onClose={() => setShowFlyout(false)}
data-test-subj="mlAnalyticsJobMapFlyout"
>
<EuiFlyoutHeader>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h3 data-test-subj="mlDataFrameAnalyticsNodeDetailsTitle">
<FormattedMessage
id="xpack.ml.dataframe.analyticsMap.flyoutHeaderTitle"
defaultMessage="Details for {type} {id}"
values={{ id: nodeLabel, type: nodeType }}
/>
</h3>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiDescriptionList
compressed
type="column"
listItems={
nodeType === 'index-pattern'
? getListItems(details[nodeId][nodeLabel])
: getListItems(details[nodeId])
}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
{nodeType !== JOB_MAP_NODE_TYPES.TRAINED_MODEL && (
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="s"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
)}
</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>
);
}
);

View file

@ -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>

View file

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

View 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>;

View file

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

View file

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

View file

@ -149,6 +149,7 @@
"InitializeJobSavedObjects",
"AssignJobsToSpaces",
"RemoveJobsFromSpaces",
"RemoveJobsFromCurrentSpace",
"JobsSpaces",
"DeleteJobCheck",

View file

@ -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
*

View file

@ -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({

View file

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