Merge pull request #9224 from jakemcdermott/add-mgmt-jobs

Add system jobs interface w/ configurable data retention

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
             https://github.com/jakemcdermott
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-02-24 19:08:55 +00:00 committed by GitHub
commit cb05b54404
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 909 additions and 29 deletions

View File

@ -15,6 +15,7 @@ This is a list of high-level changes for each release of AWX. A full list of com
- Playbook, credential type, and inventory file inputs now support type-ahead and manual type-in! https://github.com/ansible/awx/pull/9120
- Added ability to relaunch against failed hosts: https://github.com/ansible/awx/pull/9225
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
# 17.0.1 (January 26, 2021)
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152

View File

@ -29,6 +29,7 @@ import Root from './models/Root';
import Schedules from './models/Schedules';
import Settings from './models/Settings';
import SystemJobs from './models/SystemJobs';
import SystemJobTemplates from './models/SystemJobTemplates';
import Teams from './models/Teams';
import Tokens from './models/Tokens';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
@ -71,6 +72,7 @@ const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SettingsAPI = new Settings();
const SystemJobsAPI = new SystemJobs();
const SystemJobTemplatesAPI = new SystemJobTemplates();
const TeamsAPI = new Teams();
const TokensAPI = new Tokens();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
@ -114,6 +116,7 @@ export {
SchedulesAPI,
SettingsAPI,
SystemJobsAPI,
SystemJobTemplatesAPI,
TeamsAPI,
TokensAPI,
UnifiedJobTemplatesAPI,

View File

@ -9,8 +9,8 @@ const getBaseURL = type => {
case 'project':
case 'project_update':
return '/project_updates/';
case 'system':
case 'system_job':
case 'management':
case 'management_job':
return '/system_jobs/';
case 'inventory':
case 'inventory_update':

View File

@ -0,0 +1,24 @@
import Base from '../Base';
import NotificationsMixin from '../mixins/Notifications.mixin';
import SchedulesMixin from '../mixins/Schedules.mixin';
const Mixins = SchedulesMixin(NotificationsMixin(Base));
class SystemJobTemplates extends Mixins {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/system_job_templates/';
}
readDetail(id) {
const path = `${this.baseUrl}${id}/`;
return this.http.get(path).then(({ data }) => data);
}
launch(id, data) {
return this.http.post(`${this.baseUrl}${id}/launch/`, data);
}
}
export default SystemJobTemplates;

View File

@ -32,7 +32,7 @@ function JobListItem({
inventory_update: i18n._(t`Inventory Sync`),
job: i18n._(t`Playbook Run`),
ad_hoc_command: i18n._(t`Command`),
management_job: i18n._(t`Management Job`),
system_job: i18n._(t`Management Job`),
workflow_job: i18n._(t`Workflow Job`),
};

View File

@ -37,6 +37,7 @@ function PaginatedTable({
showPageSizeOptions,
i18n,
renderToolbar,
emptyContentMessage,
}) {
const history = useHistory();
@ -73,9 +74,6 @@ function PaginatedTable({
const queryParams = parseQueryString(qsConfig, history.location.search);
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
const emptyContentMessage = i18n._(
t`Please add ${pluralizedItemName} to populate this list `
);
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
let Content;
@ -85,7 +83,13 @@ function PaginatedTable({
Content = <ContentError error={contentError} />;
} else if (items.length <= 0) {
Content = (
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
<ContentEmpty
title={emptyContentTitle}
message={
emptyContentMessage ||
i18n._(t`Please add ${pluralizedItemName} to populate this list `)
}
/>
);
} else {
Content = (

View File

@ -25,6 +25,7 @@ function Schedule({
resource,
launchConfig,
surveyConfig,
hasDaysToKeepField,
}) {
const { scheduleId } = useParams();
@ -69,7 +70,7 @@ function Schedule({
},
];
if (isLoading) {
if (isLoading || !schedule?.summary_fields?.unified_job_template?.id) {
return <ContentLoading />;
}
@ -95,6 +96,7 @@ function Schedule({
if (!pathname.includes('schedules/') || pathname.endsWith('edit')) {
showCardHeader = false;
}
return (
<>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
@ -107,6 +109,7 @@ function Schedule({
{schedule && [
<Route key="edit" path={`${pathRoot}schedules/:id/edit`}>
<ScheduleEdit
hasDaysToKeepField={hasDaysToKeepField}
schedule={schedule}
resource={resource}
launchConfig={launchConfig}
@ -117,7 +120,11 @@ function Schedule({
key="details"
path={`${pathRoot}schedules/:scheduleId/details`}
>
<ScheduleDetail schedule={schedule} surveyConfig={surveyConfig} />
<ScheduleDetail
hasDaysToKeepField={hasDaysToKeepField}
schedule={schedule}
surveyConfig={surveyConfig}
/>
</Route>,
]}
<Route key="not-found" path="*">

View File

@ -15,7 +15,14 @@ import mergeExtraVars from '../../../util/prompt/mergeExtraVars';
import getSurveyValues from '../../../util/prompt/getSurveyValues';
import { getAddedAndRemoved } from '../../../util/lists';
function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) {
function ScheduleAdd({
i18n,
resource,
apiModel,
launchConfig,
surveyConfig,
hasDaysToKeepField,
}) {
const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
const location = useLocation();
@ -70,13 +77,22 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) {
try {
const rule = new RRule(buildRuleObj(values, i18n));
const requestData = {
...submitValues,
rrule: rule.toString().replace(/\n/g, ' '),
};
if (Object.keys(values).includes('daysToKeep')) {
if (requestData.extra_data) {
requestData.extra_data.days = values.daysToKeep;
} else {
requestData.extra_data = JSON.stringify({ days: values.daysToKeep });
}
}
const {
data: { id: scheduleId },
} = await apiModel.createSchedule(resource.id, {
...submitValues,
rrule: rule.toString().replace(/\n/g, ' '),
});
} = await apiModel.createSchedule(resource.id, requestData);
if (credentials?.length > 0) {
await Promise.all(
added.map(({ id: credentialId }) =>
@ -94,6 +110,7 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) {
<Card>
<CardBody>
<ScheduleForm
hasDaysToKeepField={hasDaysToKeepField}
handleCancel={() => history.push(`${pathRoot}schedules`)}
handleSubmit={handleSubmit}
submitError={formSubmitError}

View File

@ -26,6 +26,7 @@ import DeleteButton from '../../DeleteButton';
import ErrorDetail from '../../ErrorDetail';
import ChipGroup from '../../ChipGroup';
import { VariablesDetail } from '../../CodeMirrorInput';
import { parseVariableField } from '../../../util/yaml';
const PromptDivider = styled(Divider)`
margin-top: var(--pf-global--spacer--lg);
@ -42,7 +43,7 @@ const PromptDetailList = styled(DetailList)`
padding: 0px 20px;
`;
function ScheduleDetail({ schedule, i18n, surveyConfig }) {
function ScheduleDetail({ hasDaysToKeepField, schedule, i18n, surveyConfig }) {
const {
id,
created,
@ -233,6 +234,16 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
return <ContentError error={readContentError} />;
}
let daysToKeep = null;
if (hasDaysToKeepField && extra_data) {
if (typeof extra_data === 'string' && extra_data !== '') {
daysToKeep = parseVariableField(extra_data).days;
}
if (typeof extra_data === 'object') {
daysToKeep = extra_data?.days;
}
}
return (
<CardBody>
<ScheduleToggle
@ -254,6 +265,9 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
{hasDaysToKeepField ? (
<Detail label={i18n._(t`Days of Data to Keep`)} value={daysToKeep} />
) : null}
<ScheduleOccurrences preview={preview} />
<UserDateDetail
label={i18n._(t`Created`)}

View File

@ -17,6 +17,7 @@ import getSurveyValues from '../../../util/prompt/getSurveyValues';
function ScheduleEdit({
i18n,
hasDaysToKeepField,
schedule,
resource,
launchConfig,
@ -83,12 +84,22 @@ function ScheduleEdit({
try {
const rule = new RRule(buildRuleObj(values, i18n));
const {
data: { id: scheduleId },
} = await SchedulesAPI.update(schedule.id, {
const requestData = {
...submitValues,
rrule: rule.toString().replace(/\n/g, ' '),
});
};
if (Object.keys(values).includes('daysToKeep')) {
if (!requestData.extra_data) {
requestData.extra_data = JSON.stringify({ days: values.daysToKeep });
} else {
requestData.extra_data.days = values.daysToKeep;
}
}
const {
data: { id: scheduleId },
} = await SchedulesAPI.update(schedule.id, requestData);
if (values.credentials?.length > 0) {
await Promise.all([
...removed.map(({ id }) =>
@ -111,6 +122,7 @@ function ScheduleEdit({
<CardBody>
<ScheduleForm
schedule={schedule}
hasDaysToKeepField={hasDaysToKeepField}
handleCancel={() =>
history.push(`${pathRoot}schedules/${schedule.id}/details`)
}

View File

@ -16,10 +16,18 @@ function Schedules({
}) {
const match = useRouteMatch();
// For some management jobs that delete data, we want to provide an additional
// field on the scheduler for configuring the number of days to retain.
const hasDaysToKeepField = [
'cleanup_activitystream',
'cleanup_jobs',
].includes(resource?.job_type);
return (
<Switch>
<Route path={`${match.path}/add`}>
<ScheduleAdd
hasDaysToKeepField={hasDaysToKeepField}
apiModel={apiModel}
resource={resource}
launchConfig={launchConfig}
@ -28,6 +36,7 @@ function Schedules({
</Route>
<Route key="details" path={`${match.path}/:scheduleId`}>
<Schedule
hasDaysToKeepField={hasDaysToKeepField}
setBreadcrumb={setBreadcrumb}
resource={resource}
launchConfig={launchConfig}

View File

@ -25,6 +25,7 @@ import {
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
import useRequest from '../../../util/useRequest';
import { required } from '../../../util/validators';
import { parseVariableField } from '../../../util/yaml';
import FrequencyDetailSubform from './FrequencyDetailSubform';
import SchedulePromptableFields from './SchedulePromptableFields';
@ -77,7 +78,7 @@ const generateRunOnTheDay = (days = []) => {
return null;
};
function ScheduleFormFields({ i18n, zoneOptions }) {
function ScheduleFormFields({ i18n, hasDaysToKeepField, zoneOptions }) {
const [startDateTime, startDateTimeMeta] = useField({
name: 'startDateTime',
validate: required(
@ -169,6 +170,16 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
{...frequency}
/>
</FormGroup>
{hasDaysToKeepField ? (
<FormField
id="schedule-days-to-keep"
label={i18n._(t`Days of Data to Keep`)}
name="daysToKeep"
type="number"
validate={required(null, i18n)}
isRequired
/>
) : null}
{frequency.value !== 'none' && (
<SubFormLayout>
<Title size="md" headingLevel="h4">
@ -184,6 +195,7 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
}
function ScheduleForm({
hasDaysToKeepField,
handleCancel,
handleSubmit,
i18n,
@ -344,6 +356,22 @@ function ScheduleForm({
);
};
if (hasDaysToKeepField) {
let initialDaysToKeep = 30;
if (schedule?.extra_data) {
if (
typeof schedule?.extra_data === 'string' &&
schedule?.extra_data !== ''
) {
initialDaysToKeep = parseVariableField(schedule?.extra_data).days;
}
if (typeof schedule?.extra_data === 'object') {
initialDaysToKeep = schedule?.extra_data?.days;
}
}
initialValues.daysToKeep = initialDaysToKeep;
}
const overriddenValues = {};
if (Object.keys(schedule).length > 0) {
@ -487,6 +515,7 @@ function ScheduleForm({
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ScheduleFormFields
hasDaysToKeepField={hasDaysToKeepField}
i18n={i18n}
zoneOptions={zoneOptions}
{...rest}

View File

@ -2,7 +2,7 @@
export const JOB_TYPE_URL_SEGMENTS = {
job: 'playbook',
project_update: 'project',
system_job: 'system',
system_job: 'management',
inventory_update: 'inventory',
ad_hoc_command: 'command',
workflow_job: 'workflow',

View File

@ -0,0 +1,193 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Link,
Redirect,
Route,
Switch,
useLocation,
useParams,
useRouteMatch,
} from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { CaretLeftIcon } from '@patternfly/react-icons';
import { Card, PageSection } from '@patternfly/react-core';
import { SystemJobTemplatesAPI, OrganizationsAPI } from '../../api';
import ContentError from '../../components/ContentError';
import ContentLoading from '../../components/ContentLoading';
import NotificationList from '../../components/NotificationList';
import RoutedTabs from '../../components/RoutedTabs';
import { Schedules } from '../../components/Schedule';
import { useConfig } from '../../contexts/Config';
import useRequest from '../../util/useRequest';
function ManagementJob({ i18n, setBreadcrumb }) {
const basePath = '/management_jobs';
const match = useRouteMatch();
const { id } = useParams();
const { pathname } = useLocation();
const { me } = useConfig();
const [isNotificationAdmin, setIsNotificationAdmin] = useState(false);
const { isLoading, error, request, result } = useRequest(
useCallback(
() =>
Promise.all([
SystemJobTemplatesAPI.readDetail(id),
OrganizationsAPI.read({
page_size: 1,
role_level: 'notification_admin_role',
}),
]).then(([systemJobTemplate, notificationRoles]) => ({
systemJobTemplate,
notificationRoles,
})),
[id]
)
);
useEffect(() => {
request();
}, [request, pathname]);
useEffect(() => {
if (!result) return;
setIsNotificationAdmin(
Boolean(result?.notificationRoles?.data?.results?.length)
);
setBreadcrumb(result);
}, [result, setBreadcrumb, setIsNotificationAdmin]);
useEffect(() => {
if (!result) return;
setBreadcrumb(result);
}, [result, setBreadcrumb]);
const createSchedule = useCallback(
data =>
SystemJobTemplatesAPI.createSchedule(result?.systemJobTemplate.id, data),
[result]
);
const loadSchedules = useCallback(
params =>
SystemJobTemplatesAPI.readSchedules(result?.systemJobTemplate.id, params),
[result]
);
const loadScheduleOptions = useCallback(
() =>
SystemJobTemplatesAPI.readScheduleOptions(result?.systemJobTemplate.id),
[result]
);
const shouldShowNotifications =
result?.systemJobTemplate?.id &&
(isNotificationAdmin || me?.is_system_auditor);
const shouldShowSchedules = !!result?.systemJobTemplate?.id;
const tabsArray = [
{
id: 99,
link: basePath,
name: (
<>
<CaretLeftIcon />
{i18n._(t`Back to management jobs`)}
</>
),
},
];
if (shouldShowSchedules) {
tabsArray.push({
id: 0,
name: i18n._(t`Schedules`),
link: `${match.url}/schedules`,
});
}
if (shouldShowNotifications) {
tabsArray.push({
id: 1,
name: i18n._(t`Notifications`),
link: `${match.url}/notifications`,
});
}
let Tabs = <RoutedTabs tabsArray={tabsArray} />;
if (pathname.includes('edit') || pathname.includes('schedules/')) {
Tabs = null;
}
if (error) {
return (
<PageSection>
<Card>
<ContentError error={error}>
{error?.response?.status === 404 && (
<span>
{i18n._(t`Management job not found.`)}
{''}
<Link to={basePath}>{i18n._(t`View all management jobs`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (isLoading) {
return (
<PageSection>
<Card>
{Tabs}
<ContentLoading />
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card>
{Tabs}
<Switch>
<Redirect
exact
from={`${basePath}/:id`}
to={`${basePath}/:id/schedules`}
/>
{shouldShowNotifications ? (
<Route path={`${basePath}/:id/notifications`}>
<NotificationList
id={Number(result?.systemJobTemplate?.id)}
canToggleNotifications={isNotificationAdmin}
apiModel={SystemJobTemplatesAPI}
/>
</Route>
) : null}
{shouldShowSchedules ? (
<Route path={`${basePath}/:id/schedules`}>
<Schedules
apiModel={SystemJobTemplatesAPI}
resource={result.systemJobTemplate}
createSchedule={createSchedule}
loadSchedules={loadSchedules}
loadScheduleOptions={loadScheduleOptions}
setBreadcrumb={setBreadcrumb}
launchConfig={{}}
surveyConfig={{}}
/>
</Route>
) : null}
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(ManagementJob);

View File

@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button, TextInput, Tooltip } from '@patternfly/react-core';
import { RocketIcon } from '@patternfly/react-icons';
import AlertModal from '../../../components/AlertModal';
const MAX_RETENTION = 99999;
const clamp = (val, min, max) => {
if (val < min) {
return min;
}
if (val > max) {
return max;
}
return val;
};
function LaunchManagementPrompt({
i18n,
isOpen,
isLoading,
onClick,
onClose,
onConfirm,
defaultDays,
}) {
const [dataRetention, setDataRetention] = useState(defaultDays);
return (
<>
<Tooltip content={i18n._(t`Launch management job`)} position="top">
<Button
aria-label={i18n._(t`Launch management job`)}
variant="plain"
onClick={onClick}
isDisabled={isLoading}
>
<RocketIcon />
</Button>
</Tooltip>
<AlertModal
isOpen={isOpen}
variant="info"
onClose={onClose}
title={i18n._(t`Launch management job`)}
label={i18n._(t`Launch management job`)}
actions={[
<Button
id="launch-job-confirm-button"
key="delete"
variant="primary"
isDisabled={isLoading}
aria-label={i18n._(t`Launch`)}
onClick={() => onConfirm(dataRetention)}
>
{i18n._(t`Launch`)}
</Button>,
<Button
id="launch-job-cancel-button"
key="cancel"
variant="link"
aria-label={i18n._(t`Cancel`)}
onClick={onClose}
>
{i18n._(t`Cancel`)}
</Button>,
]}
>
{i18n._(t`Set how many days of data should be retained.`)}
<TextInput
value={dataRetention}
type="number"
onChange={value => setDataRetention(clamp(value, 0, MAX_RETENTION))}
aria-label={i18n._(t`Data retention period`)}
/>
</AlertModal>
</>
);
}
export default withI18n()(LaunchManagementPrompt);

View File

@ -0,0 +1,137 @@
import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { useLocation } from 'react-router-dom';
import { Card, PageSection } from '@patternfly/react-core';
import { SystemJobTemplatesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import DatalistToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedTable, {
HeaderRow,
HeaderCell,
} from '../../../components/PaginatedTable';
import { useConfig } from '../../../contexts/Config';
import { parseQueryString, getQSConfig } from '../../../util/qs';
import useRequest from '../../../util/useRequest';
import ManagementJobListItem from './ManagementJobListItem';
const QS_CONFIG = getQSConfig('system_job_templates', {
page: 1,
page_size: 20,
});
const buildSearchKeys = options => {
const actions = options?.data?.actions?.GET || {};
const searchableKeys = Object.keys(actions).filter(
key => actions[key].filterable
);
const relatedSearchableKeys = options?.data?.related_search_fields || [];
return { searchableKeys, relatedSearchableKeys };
};
const loadManagementJobs = async search => {
const params = parseQueryString(QS_CONFIG, search);
const [
{
data: { results: items, count },
},
options,
] = await Promise.all([
SystemJobTemplatesAPI.read(params),
SystemJobTemplatesAPI.readOptions(),
]);
return { items, count, options };
};
function ManagementJobList({ i18n }) {
const { search } = useLocation();
const { me } = useConfig();
const [launchError, setLaunchError] = useState(null);
const {
request,
error = false,
isLoading = true,
result: { options = {}, items = [], count = 0 },
} = useRequest(
useCallback(async () => loadManagementJobs(search), [search]),
{}
);
useEffect(() => {
request();
}, [request]);
const { searchableKeys, relatedSearchableKeys } = buildSearchKeys(options);
return (
<>
<PageSection>
<Card>
<PaginatedTable
qsConfig={QS_CONFIG}
contentError={error}
hasContentLoading={isLoading}
items={items}
itemCount={count}
pluralizedItemName={i18n._(t`Management Jobs`)}
emptyContentMessage={' '}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
]}
renderToolbar={props => (
<DatalistToolbar
{...props}
showSelectAll={false}
qsConfig={QS_CONFIG}
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
<HeaderCell>{i18n._(t`Description`)}</HeaderCell>
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
</HeaderRow>
}
renderRow={({ id, name, description, job_type }) => (
<ManagementJobListItem
key={id}
id={id}
name={name}
jobType={job_type}
description={description}
isSuperUser={me?.is_superuser}
isPrompted={['cleanup_activitystream', 'cleanup_jobs'].includes(
job_type
)}
onLaunchError={setLaunchError}
/>
)}
/>
</Card>
</PageSection>
<AlertModal
isOpen={Boolean(launchError)}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => setLaunchError(null)}
>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={launchError} />
</AlertModal>
</>
);
}
export default withI18n()(ManagementJobList);

View File

@ -0,0 +1,111 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { SystemJobTemplatesAPI } from '../../../api';
import ManagementJobList from './ManagementJobList';
jest.mock('../../../api/models/SystemJobTemplates');
const managementJobs = {
data: {
results: [
{
id: 1,
name: 'Cleanup Activity Stream',
description: 'Remove activity stream history',
job_type: 'cleanup_activitystream',
url: '/api/v2/system_job_templates/1/',
},
{
id: 2,
name: 'Cleanup Expired OAuth 2 Tokens',
description: 'Cleanup expired OAuth 2 access and refresh tokens',
job_type: 'cleanup_tokens',
url: '/api/v2/system_job_templates/2/',
},
{
id: 3,
name: 'Cleanup Expired Sessions',
description: 'Cleans out expired browser sessions',
job_type: 'cleanup_sessions',
url: '/api/v2/system_job_templates/3/',
},
{
id: 4,
name: 'Cleanup Job Details',
description: 'Remove job history older than X days',
job_type: 'cleanup_tokens',
url: '/api/v2/system_job_templates/4/',
},
],
count: 4,
},
};
const options = { data: { actions: { POST: true } } };
describe('<ManagementJobList/>', () => {
beforeEach(() => {
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
SystemJobTemplatesAPI.readOptions.mockResolvedValue(options);
});
afterEach(() => {
jest.clearAllMocks();
});
let wrapper;
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
});
test('should have data fetched and render 4 rows', async () => {
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ManagementJobListItem').length).toBe(4);
expect(SystemJobTemplatesAPI.read).toBeCalled();
expect(SystemJobTemplatesAPI.readOptions).toBeCalled();
});
test('should throw content error', async () => {
SystemJobTemplatesAPI.read.mockRejectedValue(
new Error({
response: {
config: {
method: 'GET',
url: '/api/v2/system_job_templates',
},
data: 'An error occurred',
},
})
);
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
await waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('should not render add button', async () => {
SystemJobTemplatesAPI.read.mockResolvedValue(managementJobs);
SystemJobTemplatesAPI.readOptions.mockResolvedValue({
data: { actions: { POST: false } },
});
await act(async () => {
wrapper = mountWithContexts(<ManagementJobList />);
});
waitForElement(wrapper, 'ManagementJobList', el => el.length > 0);
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
});
});

View File

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Link, useHistory } from 'react-router-dom';
import { Button, Tooltip } from '@patternfly/react-core';
import { Tr, Td } from '@patternfly/react-table';
import { RocketIcon } from '@patternfly/react-icons';
import { SystemJobTemplatesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
import LaunchManagementPrompt from './LaunchManagementPrompt';
function ManagementJobListItem({
i18n,
onLaunchError,
isPrompted,
isSuperUser,
id,
jobType,
name,
description,
}) {
const detailsUrl = `/management_jobs/${id}`;
const history = useHistory();
const [isLaunchLoading, setIsLaunchLoading] = useState(false);
const [isManagementPromptOpen, setIsManagementPromptOpen] = useState(false);
const [isManagementPromptLoading, setIsManagementPromptLoading] = useState(
false
);
const [managementPromptError, setManagementPromptError] = useState(null);
const handleManagementPromptClick = () => setIsManagementPromptOpen(true);
const handleManagementPromptClose = () => setIsManagementPromptOpen(false);
const handleManagementPromptConfirm = async days => {
setIsManagementPromptLoading(true);
try {
const { data } = await SystemJobTemplatesAPI.launch(id, {
extra_vars: { days },
});
history.push(`/jobs/management/${data.id}/output`);
} catch (error) {
setManagementPromptError(error);
} finally {
setIsManagementPromptLoading(false);
}
};
const handleLaunch = async () => {
setIsLaunchLoading(true);
try {
const { data } = await SystemJobTemplatesAPI.launch(id);
history.push(`/jobs/management/${data.id}/output`);
} catch (error) {
onLaunchError(error);
} finally {
setIsLaunchLoading(false);
}
};
return (
<>
<Tr id={`mgmt-jobs-row-${jobType ? jobType.replace('_', '-') : ''}`}>
<Td />
<Td dataLabel={i18n._(t`Name`)}>
<Link to={`${detailsUrl}`}>
<b>{name}</b>
</Link>
</Td>
<Td dataLabel={i18n._(t`Description`)}>{description}</Td>
<ActionsTd dataLabel={i18n._(t`Actions`)}>
<ActionItem
visible={isSuperUser}
tooltip={i18n._(t`Launch Management Job`)}
>
{isSuperUser ? (
<>
{isPrompted ? (
<>
<LaunchManagementPrompt
isOpen={isManagementPromptOpen}
isLoading={isManagementPromptLoading}
onClick={handleManagementPromptClick}
onClose={handleManagementPromptClose}
onConfirm={handleManagementPromptConfirm}
defaultDays={30}
/>
</>
) : (
<Tooltip
content={i18n._(t`Launch management job`)}
position="top"
>
<Button
aria-label={i18n._(t`Launch management job`)}
variant="plain"
onClick={handleLaunch}
isDisabled={isLaunchLoading}
>
<RocketIcon />
</Button>
</Tooltip>
)}{' '}
</>
) : null}
</ActionItem>
</ActionsTd>
</Tr>
{managementPromptError && (
<AlertModal
isOpen={managementPromptError}
variant="danger"
onClose={() => setManagementPromptError(null)}
title={i18n._(t`Management job launch error`)}
label={i18n._(t`Management job launch error`)}
>
<ErrorDetail error={managementPromptError} />
</AlertModal>
)}
</>
);
}
export default withI18n()(ManagementJobListItem);

View File

@ -0,0 +1,69 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ManagementJobListItem from './ManagementJobListItem';
describe('<ManagementJobListItem/>', () => {
let wrapper;
const managementJob = {
id: 3,
name: 'Cleanup Expired Sessions',
description: 'Cleans out expired browser sessions',
job_type: 'cleanup_sessions',
url: '/api/v2/system_job_templates/3/',
};
test('should mount successfully', async () => {
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ManagementJobListItem
id={managementJob.id}
name={managementJob.name}
description={managementJob.description}
isSuperUser
onLaunchError={() => {}}
/>
</tbody>
</table>
);
});
expect(wrapper.find('ManagementJobListItem').length).toBe(1);
});
test('should render the proper data', async () => {
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ManagementJobListItem
id={managementJob.id}
name={managementJob.name}
description={managementJob.description}
isSuperUser
onLaunchError={() => {}}
/>
</tbody>
</table>
);
});
expect(
wrapper
.find('Td')
.at(1)
.text()
).toBe(managementJob.name);
expect(
wrapper
.find('Td')
.at(2)
.text()
).toBe(managementJob.description);
expect(wrapper.find('RocketIcon').exists()).toBeTruthy();
});
});

View File

@ -0,0 +1 @@
export { default } from './ManagementJobList';

View File

@ -1,17 +1,53 @@
import React, { Fragment } from 'react';
import React, { useState, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Route, Switch } from 'react-router-dom';
import ScreenHeader from '../../components/ScreenHeader';
import ManagementJob from './ManagementJob';
import ManagementJobList from './ManagementJobList';
function ManagementJobs({ i18n }) {
const basePath = '/management_jobs';
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
[basePath]: i18n._(t`Management jobs`),
});
const buildBreadcrumbConfig = useCallback(
({ id, name }, nested) => {
if (!id) return;
setBreadcrumbConfig({
[basePath]: i18n._(t`Management job`),
[`${basePath}/${id}`]: name,
[`${basePath}/${id}/notifications`]: i18n._(t`Notifications`),
[`${basePath}/${id}/schedules`]: i18n._(t`Schedules`),
[`${basePath}/${id}/schedules/add`]: i18n._(t`Create New Schedule`),
[`${basePath}/${id}/schedules/${nested?.id}`]: `${nested?.name}`,
[`${basePath}/${id}/schedules/${nested?.id}/details`]: i18n._(
t`Details`
),
[`${basePath}/${id}/schedules/${nested?.id}/edit`]: i18n._(
t`Edit Details`
),
});
},
[i18n]
);
return (
<Fragment>
<ScreenHeader
streamType="none"
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
/>
</Fragment>
<>
<ScreenHeader streamType="none" breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path={`${basePath}/:id`}>
<ManagementJob setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path={basePath}>
<ManagementJobList setBreadcrumb={buildBreadcrumbConfig} />
</Route>
</Switch>
</>
);
}

View File

@ -10,17 +10,20 @@ jest.mock('react-router-dom', () => ({
describe('<ManagementJobs />', () => {
let pageWrapper;
let pageSections;
beforeEach(() => {
pageWrapper = mountWithContexts(<ManagementJobs />);
pageSections = pageWrapper.find('PageSection');
});
afterEach(() => {
pageWrapper.unmount();
});
test('initially renders without crashing', () => {
test('renders ok', () => {
expect(pageWrapper.length).toBe(1);
expect(pageWrapper.find('ScreenHeader').length).toBe(1);
expect(pageSections.length).toBe(1);
});
});