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