mirror of
https://github.com/ansible/awx.git
synced 2026-03-09 05:29:26 -02:30
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:
@@ -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
|
- 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 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 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)
|
# 17.0.1 (January 26, 2021)
|
||||||
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
- Fixed pgdocker directory permissions issue with Local Docker installer: https://github.com/ansible/awx/pull/9152
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import Root from './models/Root';
|
|||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import Settings from './models/Settings';
|
import Settings from './models/Settings';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
|
import SystemJobTemplates from './models/SystemJobTemplates';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
import Tokens from './models/Tokens';
|
import Tokens from './models/Tokens';
|
||||||
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
|
||||||
@@ -71,6 +72,7 @@ const RootAPI = new Root();
|
|||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SettingsAPI = new Settings();
|
const SettingsAPI = new Settings();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
|
const SystemJobTemplatesAPI = new SystemJobTemplates();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
const TokensAPI = new Tokens();
|
const TokensAPI = new Tokens();
|
||||||
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
|
||||||
@@ -114,6 +116,7 @@ export {
|
|||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SettingsAPI,
|
SettingsAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
|
SystemJobTemplatesAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
TokensAPI,
|
TokensAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const getBaseURL = type => {
|
|||||||
case 'project':
|
case 'project':
|
||||||
case 'project_update':
|
case 'project_update':
|
||||||
return '/project_updates/';
|
return '/project_updates/';
|
||||||
case 'system':
|
case 'management':
|
||||||
case 'system_job':
|
case 'management_job':
|
||||||
return '/system_jobs/';
|
return '/system_jobs/';
|
||||||
case 'inventory':
|
case 'inventory':
|
||||||
case 'inventory_update':
|
case 'inventory_update':
|
||||||
|
|||||||
24
awx/ui_next/src/api/models/SystemJobTemplates.js
Normal file
24
awx/ui_next/src/api/models/SystemJobTemplates.js
Normal 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;
|
||||||
@@ -32,7 +32,7 @@ function JobListItem({
|
|||||||
inventory_update: i18n._(t`Inventory Sync`),
|
inventory_update: i18n._(t`Inventory Sync`),
|
||||||
job: i18n._(t`Playbook Run`),
|
job: i18n._(t`Playbook Run`),
|
||||||
ad_hoc_command: i18n._(t`Command`),
|
ad_hoc_command: i18n._(t`Command`),
|
||||||
management_job: i18n._(t`Management Job`),
|
system_job: i18n._(t`Management Job`),
|
||||||
workflow_job: i18n._(t`Workflow Job`),
|
workflow_job: i18n._(t`Workflow Job`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function PaginatedTable({
|
|||||||
showPageSizeOptions,
|
showPageSizeOptions,
|
||||||
i18n,
|
i18n,
|
||||||
renderToolbar,
|
renderToolbar,
|
||||||
|
emptyContentMessage,
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -73,9 +74,6 @@ function PaginatedTable({
|
|||||||
const queryParams = parseQueryString(qsConfig, history.location.search);
|
const queryParams = parseQueryString(qsConfig, history.location.search);
|
||||||
|
|
||||||
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||||
const emptyContentMessage = i18n._(
|
|
||||||
t`Please add ${pluralizedItemName} to populate this list `
|
|
||||||
);
|
|
||||||
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||||
|
|
||||||
let Content;
|
let Content;
|
||||||
@@ -85,7 +83,13 @@ function PaginatedTable({
|
|||||||
Content = <ContentError error={contentError} />;
|
Content = <ContentError error={contentError} />;
|
||||||
} else if (items.length <= 0) {
|
} else if (items.length <= 0) {
|
||||||
Content = (
|
Content = (
|
||||||
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
<ContentEmpty
|
||||||
|
title={emptyContentTitle}
|
||||||
|
message={
|
||||||
|
emptyContentMessage ||
|
||||||
|
i18n._(t`Please add ${pluralizedItemName} to populate this list `)
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Content = (
|
Content = (
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function Schedule({
|
|||||||
resource,
|
resource,
|
||||||
launchConfig,
|
launchConfig,
|
||||||
surveyConfig,
|
surveyConfig,
|
||||||
|
hasDaysToKeepField,
|
||||||
}) {
|
}) {
|
||||||
const { scheduleId } = useParams();
|
const { scheduleId } = useParams();
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ function Schedule({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || !schedule?.summary_fields?.unified_job_template?.id) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ function Schedule({
|
|||||||
if (!pathname.includes('schedules/') || pathname.endsWith('edit')) {
|
if (!pathname.includes('schedules/') || pathname.endsWith('edit')) {
|
||||||
showCardHeader = false;
|
showCardHeader = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
|
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
|
||||||
@@ -107,6 +109,7 @@ function Schedule({
|
|||||||
{schedule && [
|
{schedule && [
|
||||||
<Route key="edit" path={`${pathRoot}schedules/:id/edit`}>
|
<Route key="edit" path={`${pathRoot}schedules/:id/edit`}>
|
||||||
<ScheduleEdit
|
<ScheduleEdit
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
launchConfig={launchConfig}
|
launchConfig={launchConfig}
|
||||||
@@ -117,7 +120,11 @@ function Schedule({
|
|||||||
key="details"
|
key="details"
|
||||||
path={`${pathRoot}schedules/:scheduleId/details`}
|
path={`${pathRoot}schedules/:scheduleId/details`}
|
||||||
>
|
>
|
||||||
<ScheduleDetail schedule={schedule} surveyConfig={surveyConfig} />
|
<ScheduleDetail
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
|
schedule={schedule}
|
||||||
|
surveyConfig={surveyConfig}
|
||||||
|
/>
|
||||||
</Route>,
|
</Route>,
|
||||||
]}
|
]}
|
||||||
<Route key="not-found" path="*">
|
<Route key="not-found" path="*">
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import mergeExtraVars from '../../../util/prompt/mergeExtraVars';
|
|||||||
import getSurveyValues from '../../../util/prompt/getSurveyValues';
|
import getSurveyValues from '../../../util/prompt/getSurveyValues';
|
||||||
import { getAddedAndRemoved } from '../../../util/lists';
|
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 [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -70,13 +77,22 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rule = new RRule(buildRuleObj(values, i18n));
|
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 {
|
const {
|
||||||
data: { id: scheduleId },
|
data: { id: scheduleId },
|
||||||
} = await apiModel.createSchedule(resource.id, {
|
} = await apiModel.createSchedule(resource.id, requestData);
|
||||||
...submitValues,
|
|
||||||
rrule: rule.toString().replace(/\n/g, ' '),
|
|
||||||
});
|
|
||||||
if (credentials?.length > 0) {
|
if (credentials?.length > 0) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
added.map(({ id: credentialId }) =>
|
added.map(({ id: credentialId }) =>
|
||||||
@@ -94,6 +110,7 @@ function ScheduleAdd({ i18n, resource, apiModel, launchConfig, surveyConfig }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<ScheduleForm
|
<ScheduleForm
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
handleCancel={() => history.push(`${pathRoot}schedules`)}
|
handleCancel={() => history.push(`${pathRoot}schedules`)}
|
||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
submitError={formSubmitError}
|
submitError={formSubmitError}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import DeleteButton from '../../DeleteButton';
|
|||||||
import ErrorDetail from '../../ErrorDetail';
|
import ErrorDetail from '../../ErrorDetail';
|
||||||
import ChipGroup from '../../ChipGroup';
|
import ChipGroup from '../../ChipGroup';
|
||||||
import { VariablesDetail } from '../../CodeMirrorInput';
|
import { VariablesDetail } from '../../CodeMirrorInput';
|
||||||
|
import { parseVariableField } from '../../../util/yaml';
|
||||||
|
|
||||||
const PromptDivider = styled(Divider)`
|
const PromptDivider = styled(Divider)`
|
||||||
margin-top: var(--pf-global--spacer--lg);
|
margin-top: var(--pf-global--spacer--lg);
|
||||||
@@ -42,7 +43,7 @@ const PromptDetailList = styled(DetailList)`
|
|||||||
padding: 0px 20px;
|
padding: 0px 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function ScheduleDetail({ schedule, i18n, surveyConfig }) {
|
function ScheduleDetail({ hasDaysToKeepField, schedule, i18n, surveyConfig }) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
created,
|
created,
|
||||||
@@ -233,6 +234,16 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
|
|||||||
return <ContentError error={readContentError} />;
|
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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<ScheduleToggle
|
<ScheduleToggle
|
||||||
@@ -254,6 +265,9 @@ function ScheduleDetail({ schedule, i18n, surveyConfig }) {
|
|||||||
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
|
<Detail label={i18n._(t`Last Run`)} value={formatDateString(dtend)} />
|
||||||
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
|
<Detail label={i18n._(t`Local Time Zone`)} value={timezone} />
|
||||||
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
|
<Detail label={i18n._(t`Repeat Frequency`)} value={repeatFrequency} />
|
||||||
|
{hasDaysToKeepField ? (
|
||||||
|
<Detail label={i18n._(t`Days of Data to Keep`)} value={daysToKeep} />
|
||||||
|
) : null}
|
||||||
<ScheduleOccurrences preview={preview} />
|
<ScheduleOccurrences preview={preview} />
|
||||||
<UserDateDetail
|
<UserDateDetail
|
||||||
label={i18n._(t`Created`)}
|
label={i18n._(t`Created`)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import getSurveyValues from '../../../util/prompt/getSurveyValues';
|
|||||||
|
|
||||||
function ScheduleEdit({
|
function ScheduleEdit({
|
||||||
i18n,
|
i18n,
|
||||||
|
hasDaysToKeepField,
|
||||||
schedule,
|
schedule,
|
||||||
resource,
|
resource,
|
||||||
launchConfig,
|
launchConfig,
|
||||||
@@ -83,12 +84,22 @@ function ScheduleEdit({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const rule = new RRule(buildRuleObj(values, i18n));
|
const rule = new RRule(buildRuleObj(values, i18n));
|
||||||
const {
|
const requestData = {
|
||||||
data: { id: scheduleId },
|
|
||||||
} = await SchedulesAPI.update(schedule.id, {
|
|
||||||
...submitValues,
|
...submitValues,
|
||||||
rrule: rule.toString().replace(/\n/g, ' '),
|
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) {
|
if (values.credentials?.length > 0) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...removed.map(({ id }) =>
|
...removed.map(({ id }) =>
|
||||||
@@ -111,6 +122,7 @@ function ScheduleEdit({
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<ScheduleForm
|
<ScheduleForm
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
handleCancel={() =>
|
handleCancel={() =>
|
||||||
history.push(`${pathRoot}schedules/${schedule.id}/details`)
|
history.push(`${pathRoot}schedules/${schedule.id}/details`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ function Schedules({
|
|||||||
}) {
|
}) {
|
||||||
const match = useRouteMatch();
|
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 (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${match.path}/add`}>
|
<Route path={`${match.path}/add`}>
|
||||||
<ScheduleAdd
|
<ScheduleAdd
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
apiModel={apiModel}
|
apiModel={apiModel}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
launchConfig={launchConfig}
|
launchConfig={launchConfig}
|
||||||
@@ -28,6 +36,7 @@ function Schedules({
|
|||||||
</Route>
|
</Route>
|
||||||
<Route key="details" path={`${match.path}/:scheduleId`}>
|
<Route key="details" path={`${match.path}/:scheduleId`}>
|
||||||
<Schedule
|
<Schedule
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
setBreadcrumb={setBreadcrumb}
|
setBreadcrumb={setBreadcrumb}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
launchConfig={launchConfig}
|
launchConfig={launchConfig}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
|
import { dateToInputDateTime, formatDateStringUTC } from '../../../util/dates';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
import { required } from '../../../util/validators';
|
import { required } from '../../../util/validators';
|
||||||
|
import { parseVariableField } from '../../../util/yaml';
|
||||||
import FrequencyDetailSubform from './FrequencyDetailSubform';
|
import FrequencyDetailSubform from './FrequencyDetailSubform';
|
||||||
import SchedulePromptableFields from './SchedulePromptableFields';
|
import SchedulePromptableFields from './SchedulePromptableFields';
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ const generateRunOnTheDay = (days = []) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ScheduleFormFields({ i18n, zoneOptions }) {
|
function ScheduleFormFields({ i18n, hasDaysToKeepField, zoneOptions }) {
|
||||||
const [startDateTime, startDateTimeMeta] = useField({
|
const [startDateTime, startDateTimeMeta] = useField({
|
||||||
name: 'startDateTime',
|
name: 'startDateTime',
|
||||||
validate: required(
|
validate: required(
|
||||||
@@ -169,6 +170,16 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
|
|||||||
{...frequency}
|
{...frequency}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</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' && (
|
{frequency.value !== 'none' && (
|
||||||
<SubFormLayout>
|
<SubFormLayout>
|
||||||
<Title size="md" headingLevel="h4">
|
<Title size="md" headingLevel="h4">
|
||||||
@@ -184,6 +195,7 @@ function ScheduleFormFields({ i18n, zoneOptions }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleForm({
|
function ScheduleForm({
|
||||||
|
hasDaysToKeepField,
|
||||||
handleCancel,
|
handleCancel,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
i18n,
|
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 = {};
|
const overriddenValues = {};
|
||||||
|
|
||||||
if (Object.keys(schedule).length > 0) {
|
if (Object.keys(schedule).length > 0) {
|
||||||
@@ -487,6 +515,7 @@ function ScheduleForm({
|
|||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<FormColumnLayout>
|
<FormColumnLayout>
|
||||||
<ScheduleFormFields
|
<ScheduleFormFields
|
||||||
|
hasDaysToKeepField={hasDaysToKeepField}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
zoneOptions={zoneOptions}
|
zoneOptions={zoneOptions}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export const JOB_TYPE_URL_SEGMENTS = {
|
export const JOB_TYPE_URL_SEGMENTS = {
|
||||||
job: 'playbook',
|
job: 'playbook',
|
||||||
project_update: 'project',
|
project_update: 'project',
|
||||||
system_job: 'system',
|
system_job: 'management',
|
||||||
inventory_update: 'inventory',
|
inventory_update: 'inventory',
|
||||||
ad_hoc_command: 'command',
|
ad_hoc_command: 'command',
|
||||||
workflow_job: 'workflow',
|
workflow_job: 'workflow',
|
||||||
|
|||||||
193
awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx
Normal file
193
awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx
Normal 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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './ManagementJobList';
|
||||||
@@ -1,17 +1,53 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import ScreenHeader from '../../components/ScreenHeader';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
|
import ManagementJob from './ManagementJob';
|
||||||
|
import ManagementJobList from './ManagementJobList';
|
||||||
|
|
||||||
function ManagementJobs({ i18n }) {
|
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 (
|
return (
|
||||||
<Fragment>
|
<>
|
||||||
<ScreenHeader
|
<ScreenHeader streamType="none" breadcrumbConfig={breadcrumbConfig} />
|
||||||
streamType="none"
|
<Switch>
|
||||||
breadcrumbConfig={{ '/management_jobs': i18n._(t`Management Jobs`) }}
|
<Route path={`${basePath}/:id`}>
|
||||||
/>
|
<ManagementJob setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
</Fragment>
|
</Route>
|
||||||
|
<Route path={basePath}>
|
||||||
|
<ManagementJobList setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ jest.mock('react-router-dom', () => ({
|
|||||||
|
|
||||||
describe('<ManagementJobs />', () => {
|
describe('<ManagementJobs />', () => {
|
||||||
let pageWrapper;
|
let pageWrapper;
|
||||||
|
let pageSections;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pageWrapper = mountWithContexts(<ManagementJobs />);
|
pageWrapper = mountWithContexts(<ManagementJobs />);
|
||||||
|
pageSections = pageWrapper.find('PageSection');
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
pageWrapper.unmount();
|
pageWrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
test('renders ok', () => {
|
||||||
expect(pageWrapper.length).toBe(1);
|
expect(pageWrapper.length).toBe(1);
|
||||||
expect(pageWrapper.find('ScreenHeader').length).toBe(1);
|
expect(pageWrapper.find('ScreenHeader').length).toBe(1);
|
||||||
|
expect(pageSections.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user