diff --git a/CHANGELOG.md b/CHANGELOG.md index d10327b2fb..a5c527b004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index cddf01e259..3160ebd907 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -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, diff --git a/awx/ui_next/src/api/models/Jobs.js b/awx/ui_next/src/api/models/Jobs.js index fc9bbb2334..db28e172b6 100644 --- a/awx/ui_next/src/api/models/Jobs.js +++ b/awx/ui_next/src/api/models/Jobs.js @@ -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': diff --git a/awx/ui_next/src/api/models/SystemJobTemplates.js b/awx/ui_next/src/api/models/SystemJobTemplates.js new file mode 100644 index 0000000000..5e8b395821 --- /dev/null +++ b/awx/ui_next/src/api/models/SystemJobTemplates.js @@ -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; diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index bd10bf6467..50f31400e5 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -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`), }; diff --git a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx index f800b33217..bf70c2acc6 100644 --- a/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx +++ b/awx/ui_next/src/components/PaginatedTable/PaginatedTable.jsx @@ -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 = ; } else if (items.length <= 0) { Content = ( - + ); } else { Content = ( diff --git a/awx/ui_next/src/components/Schedule/Schedule.jsx b/awx/ui_next/src/components/Schedule/Schedule.jsx index e1e59c5d85..d0243ac0bd 100644 --- a/awx/ui_next/src/components/Schedule/Schedule.jsx +++ b/awx/ui_next/src/components/Schedule/Schedule.jsx @@ -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 ; } @@ -95,6 +96,7 @@ function Schedule({ if (!pathname.includes('schedules/') || pathname.endsWith('edit')) { showCardHeader = false; } + return ( <> {showCardHeader && } @@ -107,6 +109,7 @@ function Schedule({ {schedule && [ - + , ]} diff --git a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx index 81f42f5b72..2c8836f00e 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleAdd/ScheduleAdd.jsx @@ -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 }) { history.push(`${pathRoot}schedules`)} handleSubmit={handleSubmit} submitError={formSubmitError} diff --git a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx index c9e4f17fa4..2c00d989b1 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleDetail/ScheduleDetail.jsx @@ -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 ; } + 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 ( + {hasDaysToKeepField ? ( + + ) : null} 0) { await Promise.all([ ...removed.map(({ id }) => @@ -111,6 +122,7 @@ function ScheduleEdit({ history.push(`${pathRoot}schedules/${schedule.id}/details`) } diff --git a/awx/ui_next/src/components/Schedule/Schedules.jsx b/awx/ui_next/src/components/Schedule/Schedules.jsx index 22f429dd29..f6785d8fa8 100644 --- a/awx/ui_next/src/components/Schedule/Schedules.jsx +++ b/awx/ui_next/src/components/Schedule/Schedules.jsx @@ -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 ( { 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} /> + {hasDaysToKeepField ? ( + + ) : null} {frequency.value !== 'none' && ( @@ -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} diff --git a/awx/ui_next/src/constants.js b/awx/ui_next/src/constants.js index 663814439e..f157c26d49 100644 --- a/awx/ui_next/src/constants.js +++ b/awx/ui_next/src/constants.js @@ -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', diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx new file mode 100644 index 0000000000..99d3ddac3e --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJob.jsx @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx new file mode 100644 index 0000000000..1eba707195 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/LaunchManagementPrompt.jsx @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx new file mode 100644 index 0000000000..89c7200a57 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.jsx @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx new file mode 100644 index 0000000000..1152347d77 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobList.test.jsx @@ -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); + }); +}); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx new file mode 100644 index 0000000000..06ca1b8dc4 --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.jsx @@ -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); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx new file mode 100644 index 0000000000..87ae246f5d --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/ManagementJobListItem.test.jsx @@ -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(); + }); +}); diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js new file mode 100644 index 0000000000..e55f0f261f --- /dev/null +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobList/index.js @@ -0,0 +1 @@ +export { default } from './ManagementJobList'; diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx index 94f5a077c5..5d26de2a4e 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.jsx @@ -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> + </> ); } diff --git a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx index df422fe8ec..04e9ed4894 100644 --- a/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx +++ b/awx/ui_next/src/screens/ManagementJob/ManagementJobs.test.jsx @@ -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); }); });