Add ability to cancel jobs

This commit is contained in:
Jake McDermott
2020-12-11 15:06:23 -05:00
parent 89646e7799
commit 339d430de1
3 changed files with 145 additions and 34 deletions

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { withRouter } from 'react-router-dom'; import { withRouter } from 'react-router-dom';
import { I18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
@@ -10,6 +10,7 @@ import {
InfiniteLoader, InfiniteLoader,
List, List,
} from 'react-virtualized'; } from 'react-virtualized';
import { Button } from '@patternfly/react-core';
import Ansi from 'ansi-to-html'; import Ansi from 'ansi-to-html';
import hasAnsi from 'has-ansi'; import hasAnsi from 'has-ansi';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
@@ -225,6 +226,7 @@ class JobOutput extends Component {
this.state = { this.state = {
contentError: null, contentError: null,
deletionError: null, deletionError: null,
cancelError: null,
hasContentLoading: true, hasContentLoading: true,
results: {}, results: {},
currentlyLoading: [], currentlyLoading: [],
@@ -232,6 +234,9 @@ class JobOutput extends Component {
isHostModalOpen: false, isHostModalOpen: false,
hostEvent: {}, hostEvent: {},
cssMap: {}, cssMap: {},
jobStatus: props.job.status ?? 'waiting',
showCancelPrompt: false,
cancelInProgress: false,
}; };
this.cache = new CellMeasurerCache({ this.cache = new CellMeasurerCache({
@@ -242,6 +247,9 @@ class JobOutput extends Component {
this._isMounted = false; this._isMounted = false;
this.loadJobEvents = this.loadJobEvents.bind(this); this.loadJobEvents = this.loadJobEvents.bind(this);
this.handleDeleteJob = this.handleDeleteJob.bind(this); this.handleDeleteJob = this.handleDeleteJob.bind(this);
this.handleCancelOpen = this.handleCancelOpen.bind(this);
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
this.handleCancelClose = this.handleCancelClose.bind(this);
this.rowRenderer = this.rowRenderer.bind(this); this.rowRenderer = this.rowRenderer.bind(this);
this.handleHostEventClick = this.handleHostEventClick.bind(this); this.handleHostEventClick = this.handleHostEventClick.bind(this);
this.handleHostModalClose = this.handleHostModalClose.bind(this); this.handleHostModalClose = this.handleHostModalClose.bind(this);
@@ -262,10 +270,18 @@ class JobOutput extends Component {
this.loadJobEvents(); this.loadJobEvents();
connectJobSocket(job, data => { connectJobSocket(job, data => {
if (data.counter && data.counter > this.jobSocketCounter) { if (data.group_name === 'job_events') {
this.jobSocketCounter = data.counter; if (data.counter && data.counter > this.jobSocketCounter) {
} else if (data.final_counter && data.unified_job_id === job.id) { this.jobSocketCounter = data.counter;
this.jobSocketCounter = data.final_counter; }
}
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
if (data.final_counter) {
this.jobSocketCounter = data.final_counter;
}
if (data.status) {
this.setState({ jobStatus: data.status });
}
} }
}); });
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000); this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000);
@@ -344,6 +360,26 @@ class JobOutput extends Component {
} }
} }
handleCancelOpen() {
this.setState({ showCancelPrompt: true });
}
handleCancelClose() {
this.setState({ showCancelPrompt: false });
}
async handleCancelConfirm() {
const { job, type } = this.props;
this.setState({ cancelInProgress: true });
try {
await JobsAPI.cancel(job.id, type);
} catch (cancelError) {
this.setState({ cancelError });
} finally {
this.setState({ showCancelPrompt: false, cancelInProgress: false });
}
}
async handleDeleteJob() { async handleDeleteJob() {
const { job, history } = this.props; const { job, history } = this.props;
try { try {
@@ -518,7 +554,7 @@ class JobOutput extends Component {
} }
render() { render() {
const { job } = this.props; const { job, i18n } = this.props;
const { const {
contentError, contentError,
@@ -528,6 +564,10 @@ class JobOutput extends Component {
isHostModalOpen, isHostModalOpen,
remoteRowCount, remoteRowCount,
cssMap, cssMap,
jobStatus,
showCancelPrompt,
cancelError,
cancelInProgress,
} = this.state; } = this.state;
if (hasContentLoading) { if (hasContentLoading) {
@@ -553,7 +593,12 @@ class JobOutput extends Component {
<StatusIcon status={job.status} /> <StatusIcon status={job.status} />
<h1>{job.name}</h1> <h1>{job.name}</h1>
</HeaderTitle> </HeaderTitle>
<OutputToolbar job={job} onDelete={this.handleDeleteJob} /> <OutputToolbar
job={job}
jobStatus={jobStatus}
onDelete={this.handleDeleteJob}
onCancel={this.handleCancelOpen}
/>
</OutputHeader> </OutputHeader>
<HostStatusBar counts={job.host_status_counts} /> <HostStatusBar counts={job.host_status_counts} />
<PageControls <PageControls
@@ -595,21 +640,65 @@ class JobOutput extends Component {
<OutputFooter /> <OutputFooter />
</OutputWrapper> </OutputWrapper>
</CardBody> </CardBody>
{showCancelPrompt &&
['pending', 'waiting', 'running'].includes(jobStatus) && (
<AlertModal
isOpen={showCancelPrompt}
variant="danger"
onClose={this.handleCancelClose}
title={i18n._(t`Cancel Job`)}
label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger"
isDisabled={cancelInProgress}
aria-label={i18n._(t`Cancel job`)}
onClick={this.handleCancelConfirm}
>
{i18n._(t`Cancel job`)}
</Button>,
<Button
id="cancel-job-return-button"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={this.handleCancelClose}
>
{i18n._(t`Return`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to submit the request to cancel this job?`
)}
</AlertModal>
)}
{cancelError && (
<>
<AlertModal
isOpen={cancelError}
variant="danger"
onClose={() => this.setState({ cancelError: null })}
title={i18n._(t`Job Cancel Error`)}
label={i18n._(t`Job Cancel Error`)}
>
<ErrorDetail error={cancelError} />
</AlertModal>
</>
)}
{deletionError && ( {deletionError && (
<> <>
<I18n> <AlertModal
{({ i18n }) => ( isOpen={deletionError}
<AlertModal variant="danger"
isOpen={deletionError} onClose={() => this.setState({ deletionError: null })}
variant="danger" title={i18n._(t`Job Delete Error`)}
onClose={() => this.setState({ deletionError: null })} label={i18n._(t`Job Delete Error`)}
title={i18n._(t`Job Delete Error`)} >
label={i18n._(t`Job Delete Error`)} <ErrorDetail error={deletionError} />
> </AlertModal>
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</I18n>
</> </>
)} )}
</Fragment> </Fragment>
@@ -618,4 +707,4 @@ class JobOutput extends Component {
} }
export { JobOutput as _JobOutput }; export { JobOutput as _JobOutput };
export default withRouter(JobOutput); export default withI18n()(withRouter(JobOutput));

View File

@@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { shape, func } from 'prop-types'; import { shape, func } from 'prop-types';
import { import {
MinusCircleIcon,
DownloadIcon, DownloadIcon,
RocketIcon, RocketIcon,
TrashAltIcon, TrashAltIcon,
@@ -58,7 +59,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
'inventory_update', 'inventory_update',
]; ];
const OutputToolbar = ({ i18n, job, onDelete }) => { const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type); const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
const playCount = job?.playbook_counts?.play_count; const playCount = job?.playbook_counts?.play_count;
@@ -148,19 +149,34 @@ const OutputToolbar = ({ i18n, job, onDelete }) => {
</a> </a>
</Tooltip> </Tooltip>
)} )}
{job.summary_fields.user_capabilities.start &&
['pending', 'waiting', 'running'].includes(jobStatus) && (
<Tooltip content={i18n._(t`Cancel Job`)}>
<Button
variant="plain"
aria-label={i18n._(t`Cancel Job`)}
onClick={onCancel}
>
<MinusCircleIcon />
</Button>
</Tooltip>
)}
{job.summary_fields.user_capabilities.delete && ( {job.summary_fields.user_capabilities.delete &&
<Tooltip content={i18n._(t`Delete Job`)}> ['new', 'successful', 'failed', 'error', 'canceled'].includes(
<DeleteButton jobStatus
name={job.name} ) && (
modalTitle={i18n._(t`Delete Job`)} <Tooltip content={i18n._(t`Delete Job`)}>
onConfirm={onDelete} <DeleteButton
variant="plain" name={job.name}
> modalTitle={i18n._(t`Delete Job`)}
<TrashAltIcon /> onConfirm={onDelete}
</DeleteButton> variant="plain"
</Tooltip> >
)} <TrashAltIcon />
</DeleteButton>
</Tooltip>
)}
</Wrapper> </Wrapper>
); );
}; };

View File

@@ -16,6 +16,7 @@ describe('<OutputToolbar />', () => {
failures: 2, failures: 2,
}, },
}} }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );
@@ -33,6 +34,7 @@ describe('<OutputToolbar />', () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<OutputToolbar <OutputToolbar
job={{ ...mockJobData, type: 'system_job' }} job={{ ...mockJobData, type: 'system_job' }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );
@@ -54,6 +56,7 @@ describe('<OutputToolbar />', () => {
host_status_counts: {}, host_status_counts: {},
playbook_counts: {}, playbook_counts: {},
}} }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );
@@ -74,6 +77,7 @@ describe('<OutputToolbar />', () => {
...mockJobData, ...mockJobData,
elapsed: 274265, elapsed: 274265,
}} }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );
@@ -95,6 +99,7 @@ describe('<OutputToolbar />', () => {
}, },
}, },
}} }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );
@@ -113,6 +118,7 @@ describe('<OutputToolbar />', () => {
}, },
}, },
}} }}
jobStatus="successful"
onDelete={() => {}} onDelete={() => {}}
/> />
); );