diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_actions/management.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_actions/management.js
index 756da78e801b..645b9dd2409b 100644
--- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_actions/management.js
+++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/job_actions/management.js
@@ -10,8 +10,10 @@ import { mlNodesAvailable } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
import {
stopDatafeeds,
cloneJob,
+ closeJobs,
isStartable,
isStoppable,
+ isClosable,
} from '../utils';
export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showStartDatafeedModal, refreshJobs) {
@@ -20,6 +22,7 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt
const canDeleteJob = checkPermission('canDeleteJob');
const canUpdateDatafeed = checkPermission('canUpdateDatafeed');
const canStartStopDatafeed = (checkPermission('canStartStopDatafeed') && mlNodesAvailable());
+ const canCloseJob = (checkPermission('canCloseJob') && mlNodesAvailable());
return [
{
@@ -42,6 +45,16 @@ export function actionsMenuContent(showEditJobFlyout, showDeleteJobModal, showSt
stopDatafeeds([item], refreshJobs);
closeMenu(true);
}
+ }, {
+ name: 'Close job',
+ description: 'Close job',
+ icon: 'cross',
+ enabled: () => (canCloseJob),
+ available: (item) => (isClosable([item])),
+ onClick: (item) => {
+ closeJobs([item], refreshJobs);
+ closeMenu(true);
+ }
}, {
name: 'Clone job',
description: 'Clone job',
diff --git a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/actions_menu.js
index 710370799e36..43581db20ca0 100644
--- a/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/actions_menu.js
+++ b/x-pack/plugins/ml/public/jobs/jobs_list_new/components/multi_job_actions/actions_menu.js
@@ -20,9 +20,12 @@ import {
} from '@elastic/eui';
import {
+ closeJobs,
stopDatafeeds,
isStartable,
- isStoppable } from '../utils';
+ isStoppable,
+ isClosable,
+} from '../utils';
export class MultiJobActionsMenu extends Component {
constructor(props) {
@@ -34,6 +37,7 @@ export class MultiJobActionsMenu extends Component {
this.canDeleteJob = checkPermission('canDeleteJob');
this.canStartStopDatafeed = (checkPermission('canStartStopDatafeed') && mlNodesAvailable());
+ this.canCloseJob = (checkPermission('canCloseJob') && mlNodesAvailable());
}
onButtonClick = () => {
@@ -74,6 +78,19 @@ export class MultiJobActionsMenu extends Component {
)
];
+ if(isClosable(this.props.jobs)) {
+ items.push(
+ { closeJobs(this.props.jobs); this.closePopover(); }}
+ >
+ Close job{s}
+
+ );
+ }
+
if(isStoppable(this.props.jobs)) {
items.push(
{
@@ -29,11 +29,15 @@ export function loadFullJob(jobId) {
}
export function isStartable(jobs) {
- return (jobs.find(j => j.datafeedState === DATAFEED_STATE.STOPPED) !== undefined);
+ return jobs.some(j => j.datafeedState === DATAFEED_STATE.STOPPED);
}
export function isStoppable(jobs) {
- return (jobs.find(j => j.datafeedState === DATAFEED_STATE.STARTED) !== undefined);
+ return jobs.some(j => j.datafeedState === DATAFEED_STATE.STARTED);
+}
+
+export function isClosable(jobs) {
+ return jobs.some(j => (j.datafeedState === DATAFEED_STATE.STOPPED) && (j.jobState !== JOB_STATE.CLOSED));
}
export function forceStartDatafeeds(jobs, start, end, finish = () => {}) {
@@ -69,7 +73,8 @@ function showResults(resp, action) {
const successes = [];
const failures = [];
for (const d in resp) {
- if (resp[d][action] === true || (resp[d][action] === false && resp[d].error.statusCode === 409)) {
+ if (resp[d][action] === true ||
+ (resp[d][action] === false && (resp[d].error.statusCode === 409 && action === DATAFEED_STATE.STARTED))) {
successes.push(d);
} else {
failures.push({
@@ -90,8 +95,12 @@ function showResults(resp, action) {
} else if (action === DATAFEED_STATE.DELETED) {
actionText = 'delete';
actionTextPT = 'deleted';
+ } else if (action === JOB_STATE.CLOSED) {
+ actionText = 'close';
+ actionTextPT = 'closed';
}
+
if (successes.length > 1) {
toastNotifications.addSuccess(`${successes.length} jobs ${actionTextPT} successfully`);
} else if (successes.length === 1) {
@@ -118,6 +127,20 @@ export function cloneJob(jobId) {
});
}
+export function closeJobs(jobs, finish = () => {}) {
+ const jobIds = jobs.map(j => j.id);
+ mlJobService.closeJobs(jobIds)
+ .then((resp) => {
+ showResults(resp, JOB_STATE.CLOSED);
+ finish();
+ })
+ .catch((error) => {
+ mlMessageBarService.notify.error(error);
+ toastNotifications.addDanger(`Jobs failed to close`, error);
+ finish();
+ });
+}
+
export function deleteJobs(jobs, finish = () => {}) {
const jobIds = jobs.map(j => j.id);
mlJobService.deleteJobs(jobIds)
diff --git a/x-pack/plugins/ml/public/services/job_service.js b/x-pack/plugins/ml/public/services/job_service.js
index b0698b6e8e73..a6baa69c34a7 100644
--- a/x-pack/plugins/ml/public/services/job_service.js
+++ b/x-pack/plugins/ml/public/services/job_service.js
@@ -927,6 +927,9 @@ class JobService {
return ml.jobs.deleteJobs(jIds);
}
+ closeJobs(jIds) {
+ return ml.jobs.closeJobs(jIds);
+ }
validateDetector(detector) {
return new Promise((resolve, reject) => {
diff --git a/x-pack/plugins/ml/public/services/ml_api_service/jobs.js b/x-pack/plugins/ml/public/services/ml_api_service/jobs.js
index 2ac967f7bb63..f317244b940a 100644
--- a/x-pack/plugins/ml/public/services/ml_api_service/jobs.js
+++ b/x-pack/plugins/ml/public/services/ml_api_service/jobs.js
@@ -71,6 +71,16 @@ export const jobs = {
});
},
+ closeJobs(jobIds) {
+ return http({
+ url: `${basePath}/jobs/close_jobs`,
+ method: 'POST',
+ data: {
+ jobIds,
+ }
+ });
+ },
+
jobAuditMessages(jobId, from) {
const jobIdString = (jobId !== undefined) ? `/${jobId}` : '';
const fromString = (from !== undefined) ? `?from=${from}` : '';
diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.js b/x-pack/plugins/ml/server/models/job_service/jobs.js
index f9761b404753..b236cf3af06f 100644
--- a/x-pack/plugins/ml/server/models/job_service/jobs.js
+++ b/x-pack/plugins/ml/server/models/job_service/jobs.js
@@ -47,6 +47,31 @@ export function jobsProvider(callWithRequest) {
return results;
}
+ async function closeJobs(jobIds) {
+ const results = {};
+ for (const jobId of jobIds) {
+ try {
+ await callWithRequest('ml.closeJob', { jobId });
+ results[jobId] = { closed: true };
+ } catch (error) {
+ if (error.statusCode === 409 && (error.response && error.response.includes('datafeed') === false)) {
+ // the close job request may fail (409) if the job has failed or if the datafeed hasn't been stopped.
+ // if the job has failed we want to attempt a force close.
+ // however, if we received a 409 due to the datafeed being started we should not attempt a force close.
+ try {
+ await callWithRequest('ml.closeJob', { jobId, force: true });
+ results[jobId] = { closed: true };
+ } catch (error2) {
+ results[jobId] = { closed: false, error: error2 };
+ }
+ } else {
+ results[jobId] = { closed: false, error };
+ }
+ }
+ }
+ return results;
+ }
+
async function jobsSummary(jobIds = []) {
const fullJobsList = await createFullJobsList();
const auditMessages = await getAuditMessagesSummary();
@@ -276,6 +301,7 @@ export function jobsProvider(callWithRequest) {
return {
forceDeleteJob,
deleteJobs,
+ closeJobs,
jobsSummary,
createFullJobsList,
getAllGroups,
diff --git a/x-pack/plugins/ml/server/routes/job_service.js b/x-pack/plugins/ml/server/routes/job_service.js
index d98c28527409..755585368926 100644
--- a/x-pack/plugins/ml/server/routes/job_service.js
+++ b/x-pack/plugins/ml/server/routes/job_service.js
@@ -63,6 +63,22 @@ export function jobServiceRoutes(server, commonRouteConfig) {
}
});
+ server.route({
+ method: 'POST',
+ path: '/api/ml/jobs/close_jobs',
+ handler(request, reply) {
+ const callWithRequest = callWithRequestFactory(server, request);
+ const { closeJobs } = jobServiceProvider(callWithRequest);
+ const { jobIds } = request.payload;
+ return closeJobs(jobIds)
+ .then(resp => reply(resp))
+ .catch(resp => reply(wrapError(resp)));
+ },
+ config: {
+ ...commonRouteConfig
+ }
+ });
+
server.route({
method: 'POST',
path: '/api/ml/jobs/jobs_summary',