Merge pull request #9659 from jakemcdermott/fix-7657

Support job cancellation through details panel

SUMMARY
for #7657
edit: also addresses #8838 cc @nixocio

Reviewed-by: Kersom <None>
Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
This commit is contained in:
softwarefactory-project-zuul[bot]
2021-03-25 16:58:03 +00:00
committed by GitHub
20 changed files with 319 additions and 255 deletions

View File

@@ -1,12 +0,0 @@
const RelaunchMixin = parent =>
class extends parent {
relaunch(id, data) {
return this.http.post(`${this.baseUrl}${id}/relaunch/`, data);
}
readRelaunch(id) {
return this.http.get(`${this.baseUrl}${id}/relaunch/`);
}
};
export default RelaunchMixin;

View File

@@ -0,0 +1,48 @@
const Runnable = parent =>
class extends parent {
jobEventSlug = '/events/';
cancel(id) {
const endpoint = `${this.baseUrl}${id}/cancel/`;
return this.http.post(endpoint);
}
launchUpdate(id, data) {
const endpoint = `${this.baseUrl}${id}/update/`;
return this.http.post(endpoint, data);
}
readLaunchUpdate(id) {
const endpoint = `${this.baseUrl}${id}/update/`;
return this.http.get(endpoint);
}
readEvents(id, params = {}) {
const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`;
return this.http.get(endpoint, { params });
}
readEventOptions(id) {
const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`;
return this.http.options(endpoint);
}
readRelaunch(id) {
const endpoint = `${this.baseUrl}${id}/relaunch/`;
return this.http.get(endpoint);
}
relaunch(id, data) {
const endpoint = `${this.baseUrl}${id}/relaunch/`;
return this.http.post(endpoint, data);
}
};
export default Runnable;

View File

@@ -1,11 +1,15 @@
import Base from '../Base'; import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin'; import RunnableMixin from '../mixins/Runnable.mixin';
class AdHocCommands extends RelaunchMixin(Base) { class AdHocCommands extends RunnableMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/ad_hoc_commands/'; this.baseUrl = '/api/v2/ad_hoc_commands/';
} }
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
} }
export default AdHocCommands; export default AdHocCommands;

View File

@@ -1,7 +1,7 @@
import Base from '../Base'; import Base from '../Base';
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin'; import RunnableMixin from '../mixins/Runnable.mixin';
class InventoryUpdates extends LaunchUpdateMixin(Base) { class InventoryUpdates extends RunnableMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/inventory_updates/'; this.baseUrl = '/api/v2/inventory_updates/';
@@ -11,5 +11,9 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) {
createSyncCancel(sourceId) { createSyncCancel(sourceId) {
return this.http.post(`${this.baseUrl}${sourceId}/cancel/`); return this.http.post(`${this.baseUrl}${sourceId}/cancel/`);
} }
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
} }
export default InventoryUpdates; export default InventoryUpdates;

View File

@@ -1,67 +1,23 @@
import Base from '../Base'; import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin'; import RunnableMixin from '../mixins/Runnable.mixin';
const getBaseURL = type => { class Jobs extends RunnableMixin(Base) {
switch (type) {
case 'playbook':
case 'job':
return '/jobs/';
case 'project':
case 'project_update':
return '/project_updates/';
case 'management':
case 'management_job':
return '/system_jobs/';
case 'inventory':
case 'inventory_update':
return '/inventory_updates/';
case 'command':
case 'ad_hoc_command':
return '/ad_hoc_commands/';
case 'workflow':
case 'workflow_job':
return '/workflow_jobs/';
default:
throw new Error('Unable to find matching job type');
}
};
class Jobs extends RelaunchMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/jobs/'; this.baseUrl = '/api/v2/jobs/';
this.jobEventSlug = '/job_events/';
} }
cancel(id, type) { cancel(id) {
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`); return this.http.post(`${this.baseUrl}${id}/cancel/`);
} }
readCredentials(id, type) { readCredentials(id) {
return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`); return this.http.get(`${this.baseUrl}${id}/credentials/`);
} }
readDetail(id, type) { readDetail(id) {
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`); return this.http.get(`${this.baseUrl}${id}/`);
}
readEvents(id, type = 'playbook', params = {}) {
let endpoint;
if (type === 'playbook') {
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
} else {
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
}
return this.http.get(endpoint, { params });
}
readEventOptions(id, type = 'playbook') {
let endpoint;
if (type === 'playbook') {
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
} else {
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
}
return this.http.options(endpoint);
} }
} }

View File

@@ -1,10 +1,15 @@
import Base from '../Base'; import Base from '../Base';
import RunnableMixin from '../mixins/Runnable.mixin';
class ProjectUpdates extends Base { class ProjectUpdates extends RunnableMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/project_updates/'; this.baseUrl = '/api/v2/project_updates/';
} }
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
} }
export default ProjectUpdates; export default ProjectUpdates;

View File

@@ -1,10 +1,16 @@
import Base from '../Base'; import Base from '../Base';
class SystemJobs extends Base { import RunnableMixin from '../mixins/Runnable.mixin';
class SystemJobs extends RunnableMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/system_jobs/'; this.baseUrl = '/api/v2/system_jobs/';
} }
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
} }
export default SystemJobs; export default SystemJobs;

View File

@@ -1,7 +1,7 @@
import Base from '../Base'; import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin'; import RunnableMixin from '../mixins/Runnable.mixin';
class WorkflowJobs extends RelaunchMixin(Base) { class WorkflowJobs extends RunnableMixin(Base) {
constructor(http) { constructor(http) {
super(http); super(http);
this.baseUrl = '/api/v2/workflow_jobs/'; this.baseUrl = '/api/v2/workflow_jobs/';
@@ -10,6 +10,10 @@ class WorkflowJobs extends RelaunchMixin(Base) {
readNodes(id, params) { readNodes(id, params) {
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params }); return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
} }
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
} }
export default WorkflowJobs; export default WorkflowJobs;

View File

@@ -13,20 +13,12 @@ import useRequest, {
useDeleteItems, useDeleteItems,
useDismissableError, useDismissableError,
} from '../../util/useRequest'; } from '../../util/useRequest';
import isJobRunning from '../../util/jobs'; import { isJobRunning, getJobModel } from '../../util/jobs';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import JobListItem from './JobListItem'; import JobListItem from './JobListItem';
import JobListCancelButton from './JobListCancelButton'; import JobListCancelButton from './JobListCancelButton';
import useWsJobs from './useWsJobs'; import useWsJobs from './useWsJobs';
import { import { UnifiedJobsAPI } from '../../api';
AdHocCommandsAPI,
InventoryUpdatesAPI,
JobsAPI,
ProjectUpdatesAPI,
SystemJobsAPI,
UnifiedJobsAPI,
WorkflowJobsAPI,
} from '../../api';
function JobList({ i18n, defaultParams, showTypeColumn = false }) { function JobList({ i18n, defaultParams, showTypeColumn = false }) {
const qsConfig = getQSConfig( const qsConfig = getQSConfig(
@@ -104,7 +96,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
return Promise.all( return Promise.all(
selected.map(job => { selected.map(job => {
if (isJobRunning(job.status)) { if (isJobRunning(job.status)) {
return JobsAPI.cancel(job.id, job.type); return getJobModel(job.type).cancel(job.id);
} }
return Promise.resolve(); return Promise.resolve();
}) })
@@ -127,22 +119,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
useCallback(() => { useCallback(() => {
return Promise.all( return Promise.all(
selected.map(({ type, id }) => { selected.map(({ type, id }) => {
switch (type) { return getJobModel(type).destroy(id);
case 'job':
return JobsAPI.destroy(id);
case 'ad_hoc_command':
return AdHocCommandsAPI.destroy(id);
case 'system_job':
return SystemJobsAPI.destroy(id);
case 'project_update':
return ProjectUpdatesAPI.destroy(id);
case 'inventory_update':
return InventoryUpdatesAPI.destroy(id);
case 'workflow_job':
return WorkflowJobsAPI.destroy(id);
default:
return null;
}
}) })
); );
}, [selected]), }, [selected]),

View File

@@ -319,13 +319,12 @@ describe('<JobList />', () => {
wrapper.find('JobListCancelButton').invoke('onCancel')(); wrapper.find('JobListCancelButton').invoke('onCancel')();
}); });
expect(JobsAPI.cancel).toHaveBeenCalledTimes(6); expect(ProjectUpdatesAPI.cancel).toHaveBeenCalledWith(1);
expect(JobsAPI.cancel).toHaveBeenCalledWith(1, 'project_update'); expect(JobsAPI.cancel).toHaveBeenCalledWith(2);
expect(JobsAPI.cancel).toHaveBeenCalledWith(2, 'job'); expect(InventoryUpdatesAPI.cancel).toHaveBeenCalledWith(3);
expect(JobsAPI.cancel).toHaveBeenCalledWith(3, 'inventory_update'); expect(WorkflowJobsAPI.cancel).toHaveBeenCalledWith(4);
expect(JobsAPI.cancel).toHaveBeenCalledWith(4, 'workflow_job'); expect(SystemJobsAPI.cancel).toHaveBeenCalledWith(5);
expect(JobsAPI.cancel).toHaveBeenCalledWith(5, 'system_job'); expect(AdHocCommandsAPI.cancel).toHaveBeenCalledWith(6);
expect(JobsAPI.cancel).toHaveBeenCalledWith(6, 'ad_hoc_command');
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });

View File

@@ -4,7 +4,7 @@ import { t } from '@lingui/macro';
import { arrayOf, func } from 'prop-types'; import { arrayOf, func } from 'prop-types';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { KebabifiedContext } from '../../contexts/Kebabified'; import { KebabifiedContext } from '../../contexts/Kebabified';
import isJobRunning from '../../util/jobs'; import { isJobRunning } from '../../util/jobs';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { Job } from '../../types'; import { Job } from '../../types';

View File

@@ -12,20 +12,32 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { CaretLeftIcon } from '@patternfly/react-icons'; import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core'; import { Card, PageSection } from '@patternfly/react-core';
import { JobsAPI } from '../../api';
import ContentError from '../../components/ContentError'; import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading'; import ContentLoading from '../../components/ContentLoading';
import RoutedTabs from '../../components/RoutedTabs'; import RoutedTabs from '../../components/RoutedTabs';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { getJobModel } from '../../util/jobs';
import JobDetail from './JobDetail'; import JobDetail from './JobDetail';
import JobOutput from './JobOutput'; import JobOutput from './JobOutput';
import { WorkflowOutput } from './WorkflowOutput'; import { WorkflowOutput } from './WorkflowOutput';
import useWsJob from './useWsJob'; import useWsJob from './useWsJob';
// maps the displayed url segments to actual api types
export const JOB_URL_SEGMENT_MAP = {
playbook: 'job',
project: 'project_update',
management: 'system_job',
inventory: 'inventory_update',
command: 'ad_hoc_command',
workflow: 'workflow_job',
};
function Job({ i18n, setBreadcrumb }) { function Job({ i18n, setBreadcrumb }) {
const { id, type } = useParams(); const { id, typeSegment } = useParams();
const match = useRouteMatch(); const match = useRouteMatch();
const type = JOB_URL_SEGMENT_MAP[typeSegment];
const { const {
isLoading, isLoading,
error, error,
@@ -34,12 +46,11 @@ function Job({ i18n, setBreadcrumb }) {
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
let eventOptions = {}; let eventOptions = {};
const { data: jobDetailData } = await JobsAPI.readDetail(id, type); const { data: jobDetailData } = await getJobModel(type).readDetail(id);
if (jobDetailData.type !== 'workflow_job') { if (type !== 'workflow_job') {
const { data: jobEventOptions } = await JobsAPI.readEventOptions( const { data: jobEventOptions } = await getJobModel(
id,
type type
); ).readEventOptions(id);
eventOptions = jobEventOptions; eventOptions = jobEventOptions;
} }
if ( if (
@@ -49,7 +60,7 @@ function Job({ i18n, setBreadcrumb }) {
) { ) {
const { const {
data: { results }, data: { results },
} = await JobsAPI.readCredentials(jobDetailData.id, type); } = await getJobModel(type).readCredentials(jobDetailData.id);
jobDetailData.summary_fields.credentials = results; jobDetailData.summary_fields.credentials = results;
} }
@@ -125,37 +136,37 @@ function Job({ i18n, setBreadcrumb }) {
<Card> <Card>
<RoutedTabs tabsArray={tabsArray} /> <RoutedTabs tabsArray={tabsArray} />
<Switch> <Switch>
<Redirect from="/jobs/:type/:id" to="/jobs/:type/:id/output" exact /> <Redirect
{job && from="/jobs/:typeSegment/:id"
job.type === 'workflow_job' && [ to="/jobs/:typeSegment/:id/output"
<Route key="workflow-details" path="/jobs/workflow/:id/details"> exact
<JobDetail type={match.params.type} job={job} /> />
</Route>, {job && [
<Route key="workflow-output" path="/jobs/workflow/:id/output"> <Route
key={job.type === 'workflow_job' ? 'workflow-details' : 'details'}
path="/jobs/:typeSegment/:id/details"
>
<JobDetail job={job} />
</Route>,
<Route key="output" path="/jobs/:typeSegment/:id/output">
{job.type === 'workflow_job' ? (
<WorkflowOutput job={job} /> <WorkflowOutput job={job} />
</Route>, ) : (
]}
{job &&
job.type !== 'workflow_job' && [
<Route key="details" path="/jobs/:type/:id/details">
<JobDetail type={type} job={job} />
</Route>,
<Route key="output" path="/jobs/:type/:id/output">
<JobOutput <JobOutput
type={type}
job={job} job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys} eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys} eventSearchableKeys={eventSearchableKeys}
/> />
</Route>, )}
<Route key="not-found" path="*"> </Route>,
<ContentError isNotFound> <Route key="not-found" path="*">
<Link to={`/jobs/${type}/${id}/details`}> <ContentError isNotFound>
{i18n._(t`View Job Details`)} <Link to={`/jobs/${typeSegment}/${id}/details`}>
</Link> {i18n._(t`View Job Details`)}
</ContentError> </Link>
</Route>, </ContentError>
]} </Route>,
]}
</Switch> </Switch>
</Card> </Card>
</PageSection> </PageSection>

View File

@@ -1,5 +1,5 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -25,17 +25,11 @@ import {
} from '../../../components/LaunchButton'; } from '../../../components/LaunchButton';
import StatusIcon from '../../../components/StatusIcon'; import StatusIcon from '../../../components/StatusIcon';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail'; import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import { getJobModel, isJobRunning } from '../../../util/jobs';
import { toTitleCase } from '../../../util/strings'; import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { formatDateString } from '../../../util/dates'; import { formatDateString } from '../../../util/dates';
import { Job } from '../../../types'; import { Job } from '../../../types';
import {
JobsAPI,
ProjectUpdatesAPI,
SystemJobsAPI,
WorkflowJobsAPI,
InventoriesAPI,
AdHocCommandsAPI,
} from '../../../api';
const VariablesInput = styled(_VariablesInput)` const VariablesInput = styled(_VariablesInput)`
.pf-c-form__label { .pf-c-form__label {
@@ -77,6 +71,24 @@ function JobDetail({ job, i18n }) {
const [errorMsg, setErrorMsg] = useState(); const [errorMsg, setErrorMsg] = useState();
const history = useHistory(); const history = useHistory();
const [showCancelModal, setShowCancelModal] = useState(false);
const {
error: cancelError,
isLoading: isCancelling,
request: cancelJob,
} = useRequest(
useCallback(async () => {
await getJobModel(job.type).cancel(job.id, job.type);
}, [job.id, job.type]),
{}
);
const {
error: dismissableCancelError,
dismissError: dismissCancelError,
} = useDismissableError(cancelError);
const jobTypes = { const jobTypes = {
project_update: i18n._(t`Source Control Update`), project_update: i18n._(t`Source Control Update`),
inventory_update: i18n._(t`Inventory Sync`), inventory_update: i18n._(t`Inventory Sync`),
@@ -91,25 +103,7 @@ function JobDetail({ job, i18n }) {
const deleteJob = async () => { const deleteJob = async () => {
try { try {
switch (job.type) { await getJobModel(job.type).destroy(job.id);
case 'project_update':
await ProjectUpdatesAPI.destroy(job.id);
break;
case 'system_job':
await SystemJobsAPI.destroy(job.id);
break;
case 'workflow_job':
await WorkflowJobsAPI.destroy(job.id);
break;
case 'ad_hoc_command':
await AdHocCommandsAPI.destroy(job.id);
break;
case 'inventory_update':
await InventoriesAPI.destroy(job.id);
break;
default:
await JobsAPI.destroy(job.id);
}
history.push('/jobs'); history.push('/jobs');
} catch (err) { } catch (err) {
setErrorMsg(err); setErrorMsg(err);
@@ -410,16 +404,75 @@ function JobDetail({ job, i18n }) {
)} )}
</LaunchButton> </LaunchButton>
))} ))}
{job.summary_fields.user_capabilities.delete && ( {isJobRunning(job.status) &&
<DeleteButton job?.summary_fields?.user_capabilities?.start && (
name={job.name} <Button
modalTitle={i18n._(t`Delete Job`)} variant="secondary"
onConfirm={deleteJob} aria-label={i18n._(t`Cancel`)}
> isDisabled={isCancelling}
{i18n._(t`Delete`)} onClick={() => setShowCancelModal(true)}
</DeleteButton> ouiaId="job-detail-cancel-button"
)} >
{i18n._(t`Cancel`)}
</Button>
)}
{!isJobRunning(job.status) &&
job?.summary_fields?.user_capabilities?.delete && (
<DeleteButton
name={job.name}
modalTitle={i18n._(t`Delete Job`)}
onConfirm={deleteJob}
ouiaId="job-detail-delete-button"
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow> </CardActionsRow>
{showCancelModal && isJobRunning(job.status) && (
<AlertModal
isOpen={showCancelModal}
variant="danger"
onClose={() => setShowCancelModal(false)}
title={i18n._(t`Cancel Job`)}
label={i18n._(t`Cancel Job`)}
actions={[
<Button
id="cancel-job-confirm-button"
key="delete"
variant="danger"
isDisabled={isCancelling}
aria-label={i18n._(t`Cancel job`)}
onClick={cancelJob}
>
{i18n._(t`Cancel job`)}
</Button>,
<Button
id="cancel-job-return-button"
key="cancel"
variant="secondary"
aria-label={i18n._(t`Return`)}
onClick={() => setShowCancelModal(false)}
>
{i18n._(t`Return`)}
</Button>,
]}
>
{i18n._(
t`Are you sure you want to submit the request to cancel this job?`
)}
</AlertModal>
)}
{dismissableCancelError && (
<AlertModal
isOpen={dismissableCancelError}
variant="danger"
onClose={dismissCancelError}
title={i18n._(t`Job Cancel Error`)}
label={i18n._(t`Job Cancel Error`)}
>
<ErrorDetail error={dismissableCancelError} />
</AlertModal>
)}
{errorMsg && ( {errorMsg && (
<AlertModal <AlertModal
isOpen={errorMsg} isOpen={errorMsg}

View File

@@ -37,7 +37,7 @@ import PageControls from './PageControls';
import HostEventModal from './HostEventModal'; import HostEventModal from './HostEventModal';
import { HostStatusBar, OutputToolbar } from './shared'; import { HostStatusBar, OutputToolbar } from './shared';
import getRowRangePageSize from './shared/jobOutputUtils'; import getRowRangePageSize from './shared/jobOutputUtils';
import isJobRunning from '../../../util/jobs'; import { getJobModel, isJobRunning } from '../../../util/jobs';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import { import {
encodeNonDefaultQueryString, encodeNonDefaultQueryString,
@@ -47,14 +47,6 @@ import {
removeParams, removeParams,
getQSConfig, getQSConfig,
} from '../../../util/qs'; } from '../../../util/qs';
import {
JobsAPI,
ProjectUpdatesAPI,
SystemJobsAPI,
WorkflowJobsAPI,
InventoriesAPI,
AdHocCommandsAPI,
} from '../../../api';
const QS_CONFIG = getQSConfig('job_output', { const QS_CONFIG = getQSConfig('job_output', {
order_by: 'start_line', order_by: 'start_line',
@@ -280,12 +272,7 @@ const cache = new CellMeasurerCache({
defaultHeight: 25, defaultHeight: 25,
}); });
function JobOutput({ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
job,
type,
eventRelatedSearchableKeys,
eventSearchableKeys,
}) {
const location = useLocation(); const location = useLocation();
const listRef = useRef(null); const listRef = useRef(null);
const isMounted = useRef(false); const isMounted = useRef(false);
@@ -348,8 +335,8 @@ function JobOutput({
request: cancelJob, request: cancelJob,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
await JobsAPI.cancel(job.id, type); await getJobModel(job.type).cancel(job.id);
}, [job.id, type]), }, [job.id, job.type]),
{} {}
); );
@@ -364,27 +351,10 @@ function JobOutput({
error: deleteError, error: deleteError,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
switch (job.type) { await getJobModel(job.type).destroy(job.id);
case 'project_update':
await ProjectUpdatesAPI.destroy(job.id);
break;
case 'system_job':
await SystemJobsAPI.destroy(job.id);
break;
case 'workflow_job':
await WorkflowJobsAPI.destroy(job.id);
break;
case 'ad_hoc_command':
await AdHocCommandsAPI.destroy(job.id);
break;
case 'inventory_update':
await InventoriesAPI.destroy(job.id);
break;
default:
await JobsAPI.destroy(job.id);
}
history.push('/jobs'); history.push('/jobs');
}, [job, history]) }, [job.type, job.id, history])
); );
const { const {
@@ -417,7 +387,7 @@ function JobOutput({
try { try {
const { const {
data: { results: fetchedEvents = [], count }, data: { results: fetchedEvents = [], count },
} = await JobsAPI.readEvents(job.id, type, { } = await getJobModel(job.type).readEvents(job.id, {
page: 1, page: 1,
page_size: 50, page_size: 50,
...parseQueryString(QS_CONFIG, location.search), ...parseQueryString(QS_CONFIG, location.search),
@@ -557,31 +527,33 @@ function JobOutput({
...parseQueryString(QS_CONFIG, location.search), ...parseQueryString(QS_CONFIG, location.search),
}; };
return JobsAPI.readEvents(job.id, type, params).then(response => { return getJobModel(job.type)
if (isMounted.current) { .readEvents(job.id, params)
const newResults = {}; .then(response => {
let newResultsCssMap = {}; if (isMounted.current) {
response.data.results.forEach((jobEvent, index) => { const newResults = {};
newResults[firstIndex + index] = jobEvent; let newResultsCssMap = {};
const { lineCssMap } = getLineTextHtml(jobEvent); response.data.results.forEach((jobEvent, index) => {
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap }; newResults[firstIndex + index] = jobEvent;
}); const { lineCssMap } = getLineTextHtml(jobEvent);
setResults(prevResults => ({ newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
...prevResults, });
...newResults, setResults(prevResults => ({
})); ...prevResults,
setCssMap(prevCssMap => ({ ...newResults,
...prevCssMap, }));
...newResultsCssMap, setCssMap(prevCssMap => ({
})); ...prevCssMap,
setCurrentlyLoading(prevCurrentlyLoading => ...newResultsCssMap,
prevCurrentlyLoading.filter(n => !loadRange.includes(n)) }));
); setCurrentlyLoading(prevCurrentlyLoading =>
loadRange.forEach(n => { prevCurrentlyLoading.filter(n => !loadRange.includes(n))
cache.clear(n); );
}); loadRange.forEach(n => {
} cache.clear(n);
}); });
}
});
}; };
const scrollToRow = rowIndex => { const scrollToRow = rowIndex => {

View File

@@ -278,7 +278,7 @@ describe('<JobOutput />', () => {
wrapper.find(searchBtn).simulate('click'); wrapper.find(searchBtn).simulate('click');
}); });
wrapper.update(); wrapper.update();
expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, undefined, { expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, {
order_by: 'start_line', order_by: 'start_line',
page: 1, page: 1,
page_size: 50, page_size: 50,

View File

@@ -55,8 +55,8 @@ function JobTypeRedirect({ id, path, view, i18n }) {
</PageSection> </PageSection>
); );
} }
const type = JOB_TYPE_URL_SEGMENTS[job.type]; const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type];
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />; return <Redirect from={path} to={`/jobs/${typeSegment}/${job.id}/${view}`} />;
} }
JobTypeRedirect.defaultProps = { JobTypeRedirect.defaultProps = {

View File

@@ -21,12 +21,12 @@ function Jobs({ i18n }) {
return; return;
} }
const type = JOB_TYPE_URL_SEGMENTS[job.type]; const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type];
setBreadcrumbConfig({ setBreadcrumbConfig({
'/jobs': i18n._(t`Jobs`), '/jobs': i18n._(t`Jobs`),
[`/jobs/${type}/${job.id}`]: `${job.name}`, [`/jobs/${typeSegment}/${job.id}`]: `${job.name}`,
[`/jobs/${type}/${job.id}/output`]: i18n._(t`Output`), [`/jobs/${typeSegment}/${job.id}/output`]: i18n._(t`Output`),
[`/jobs/${type}/${job.id}/details`]: i18n._(t`Details`), [`/jobs/${typeSegment}/${job.id}/details`]: i18n._(t`Details`),
}); });
}, },
[i18n] [i18n]
@@ -53,7 +53,7 @@ function Jobs({ i18n }) {
<Route path={`${match.path}/:id/output`}> <Route path={`${match.path}/:id/output`}>
<TypeRedirect view="output" /> <TypeRedirect view="output" />
</Route> </Route>
<Route path={`${match.path}/:type/:id`}> <Route path={`${match.path}/:typeSegment/:id`}>
<Job setBreadcrumb={buildBreadcrumbConfig} /> <Job setBreadcrumb={buildBreadcrumbConfig} />
</Route> </Route>
<Route path={`${match.path}/:id`}> <Route path={`${match.path}/:id`}>

View File

@@ -1,10 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useWebsocket from '../../util/useWebsocket'; import useWebsocket from '../../util/useWebsocket';
import { JobsAPI } from '../../api'; import { getJobModel } from '../../util/jobs';
export default function useWsJob(initialJob) { export default function useWsJob(initialJob) {
const { type } = useParams();
const [job, setJob] = useState(initialJob); const [job, setJob] = useState(initialJob);
const lastMessage = useWebsocket({ const lastMessage = useWebsocket({
jobs: ['status_changed'], jobs: ['status_changed'],
@@ -18,7 +16,7 @@ export default function useWsJob(initialJob) {
useEffect( useEffect(
function parseWsMessage() { function parseWsMessage() {
async function fetchJob() { async function fetchJob() {
const { data } = await JobsAPI.readDetail(job.id, type); const { data } = await getJobModel(job.type).readDetail(job.id);
setJob(data); setJob(data);
} }

View File

@@ -1,3 +1,22 @@
export default function isJobRunning(status) { import {
JobsAPI,
ProjectUpdatesAPI,
SystemJobsAPI,
WorkflowJobsAPI,
InventoryUpdatesAPI,
AdHocCommandsAPI,
} from '../api';
export function isJobRunning(status) {
return ['new', 'pending', 'waiting', 'running'].includes(status); return ['new', 'pending', 'waiting', 'running'].includes(status);
} }
export function getJobModel(type) {
if (type === 'ad_hoc_command') return AdHocCommandsAPI;
if (type === 'inventory_update') return InventoryUpdatesAPI;
if (type === 'project_update') return ProjectUpdatesAPI;
if (type === 'system_job') return SystemJobsAPI;
if (type === 'workflow_job') return WorkflowJobsAPI;
return JobsAPI;
}

View File

@@ -1,4 +1,4 @@
import isJobRunning from './jobs'; import { getJobModel, isJobRunning } from './jobs';
describe('isJobRunning', () => { describe('isJobRunning', () => {
test('should return true for new', () => { test('should return true for new', () => {
@@ -23,3 +23,23 @@ describe('isJobRunning', () => {
expect(isJobRunning('failed')).toBe(false); expect(isJobRunning('failed')).toBe(false);
}); });
}); });
describe('getJobModel', () => {
test('should return valid job model in all cases', () => {
const baseUrls = [];
[
'ad_hoc_command',
'inventory_update',
'project_update',
'system_job',
'workflow_job',
'job',
'default',
].forEach(type => {
expect(getJobModel(type)).toHaveProperty('http');
expect(getJobModel(type).jobEventSlug).toBeDefined();
baseUrls.push(getJobModel(type).baseUrl);
});
expect(new Set(baseUrls).size).toBe(baseUrls.length - 1);
});
});