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
commit a7992d06e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 RelaunchMixin from '../mixins/Relaunch.mixin';
import RunnableMixin from '../mixins/Runnable.mixin';
class AdHocCommands extends RelaunchMixin(Base) {
class AdHocCommands extends RunnableMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/ad_hoc_commands/';
}
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
}
export default AdHocCommands;

View File

@ -1,7 +1,7 @@
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) {
super(http);
this.baseUrl = '/api/v2/inventory_updates/';
@ -11,5 +11,9 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) {
createSyncCancel(sourceId) {
return this.http.post(`${this.baseUrl}${sourceId}/cancel/`);
}
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
}
export default InventoryUpdates;

View File

@ -1,67 +1,23 @@
import Base from '../Base';
import RelaunchMixin from '../mixins/Relaunch.mixin';
import RunnableMixin from '../mixins/Runnable.mixin';
const getBaseURL = type => {
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) {
class Jobs extends RunnableMixin(Base) {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/jobs/';
this.jobEventSlug = '/job_events/';
}
cancel(id, type) {
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
cancel(id) {
return this.http.post(`${this.baseUrl}${id}/cancel/`);
}
readCredentials(id, type) {
return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`);
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
readDetail(id, type) {
return this.http.get(`/api/v2${getBaseURL(type)}${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);
readDetail(id) {
return this.http.get(`${this.baseUrl}${id}/`);
}
}

View File

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

View File

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

View File

@ -1,7 +1,7 @@
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) {
super(http);
this.baseUrl = '/api/v2/workflow_jobs/';
@ -10,6 +10,10 @@ class WorkflowJobs extends RelaunchMixin(Base) {
readNodes(id, params) {
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
}
readCredentials(id) {
return this.http.get(`${this.baseUrl}${id}/credentials/`);
}
}
export default WorkflowJobs;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import 'styled-components/macro';
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -25,17 +25,11 @@ import {
} from '../../../components/LaunchButton';
import StatusIcon from '../../../components/StatusIcon';
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
import { getJobModel, isJobRunning } from '../../../util/jobs';
import { toTitleCase } from '../../../util/strings';
import useRequest, { useDismissableError } from '../../../util/useRequest';
import { formatDateString } from '../../../util/dates';
import { Job } from '../../../types';
import {
JobsAPI,
ProjectUpdatesAPI,
SystemJobsAPI,
WorkflowJobsAPI,
InventoriesAPI,
AdHocCommandsAPI,
} from '../../../api';
const VariablesInput = styled(_VariablesInput)`
.pf-c-form__label {
@ -77,6 +71,24 @@ function JobDetail({ job, i18n }) {
const [errorMsg, setErrorMsg] = useState();
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 = {
project_update: i18n._(t`Source Control Update`),
inventory_update: i18n._(t`Inventory Sync`),
@ -91,25 +103,7 @@ function JobDetail({ job, i18n }) {
const deleteJob = async () => {
try {
switch (job.type) {
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);
}
await getJobModel(job.type).destroy(job.id);
history.push('/jobs');
} catch (err) {
setErrorMsg(err);
@ -410,16 +404,75 @@ function JobDetail({ job, i18n }) {
)}
</LaunchButton>
))}
{job.summary_fields.user_capabilities.delete && (
<DeleteButton
name={job.name}
modalTitle={i18n._(t`Delete Job`)}
onConfirm={deleteJob}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
{isJobRunning(job.status) &&
job?.summary_fields?.user_capabilities?.start && (
<Button
variant="secondary"
aria-label={i18n._(t`Cancel`)}
isDisabled={isCancelling}
onClick={() => setShowCancelModal(true)}
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>
{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 && (
<AlertModal
isOpen={errorMsg}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,8 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useWebsocket from '../../util/useWebsocket';
import { JobsAPI } from '../../api';
import { getJobModel } from '../../util/jobs';
export default function useWsJob(initialJob) {
const { type } = useParams();
const [job, setJob] = useState(initialJob);
const lastMessage = useWebsocket({
jobs: ['status_changed'],
@ -18,7 +16,7 @@ export default function useWsJob(initialJob) {
useEffect(
function parseWsMessage() {
async function fetchJob() {
const { data } = await JobsAPI.readDetail(job.id, type);
const { data } = await getJobModel(job.type).readDetail(job.id);
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);
}
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', () => {
test('should return true for new', () => {
@ -23,3 +23,23 @@ describe('isJobRunning', () => {
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);
});
});