diff --git a/awx/main/analytics/core.py b/awx/main/analytics/core.py index 3ff61b82f9..bab62b4a3c 100644 --- a/awx/main/analytics/core.py +++ b/awx/main/analytics/core.py @@ -180,7 +180,8 @@ def ship(path): auth=(rh_user, rh_password), headers=s.headers, timeout=(31, 31)) - if response.status_code != 202: + # Accept 2XX status_codes + if response.status_code >= 300: return logger.exception('Upload failed with status {}, {}'.format(response.status_code, response.text)) run_now = now() diff --git a/awx/main/credential_plugins/hashivault.py b/awx/main/credential_plugins/hashivault.py index 2406623231..28f213061b 100644 --- a/awx/main/credential_plugins/hashivault.py +++ b/awx/main/credential_plugins/hashivault.py @@ -152,7 +152,7 @@ def kv_backend(**kwargs): sess = requests.Session() sess.headers['Authorization'] = 'Bearer {}'.format(token) - # Compatability header for older installs of Hashicorp Vault + # Compatibility header for older installs of Hashicorp Vault sess.headers['X-Vault-Token'] = token if api_version == 'v2': diff --git a/awx/main/management/commands/provision_instance.py b/awx/main/management/commands/provision_instance.py index b0b4474622..4d7655821a 100644 --- a/awx/main/management/commands/provision_instance.py +++ b/awx/main/management/commands/provision_instance.py @@ -13,7 +13,7 @@ from django.core.management.base import BaseCommand, CommandError class Command(BaseCommand): """ Internal tower command. - Regsiter this instance with the database for HA tracking. + Register this instance with the database for HA tracking. """ help = ( diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 3e70f93c19..ec685539b9 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2169,7 +2169,7 @@ class RunProjectUpdate(BaseTask): self._write_extra_vars_file(private_data_dir, extra_vars) def build_cwd(self, project_update, private_data_dir): - return self.get_path_to('..', 'playbooks') + return os.path.join(private_data_dir, 'project') def build_playbook_path_relative_to_cwd(self, project_update, private_data_dir): return os.path.join('project_update.yml') @@ -2310,6 +2310,12 @@ class RunProjectUpdate(BaseTask): shutil.rmtree(stage_path) os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections + # the project update playbook is not in a git repo, but uses a vendoring directory + # to be consistent with the ansible-runner model, + # that is moved into the runner projecct folder here + awx_playbooks = self.get_path_to('..', 'playbooks') + copy_tree(awx_playbooks, os.path.join(private_data_dir, 'project')) + @staticmethod def clear_project_cache(cache_dir, keep_value): if os.path.isdir(cache_dir): diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index c3cfc1167f..c7dab6f76b 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -7,6 +7,7 @@ import Credentials from './models/Credentials'; import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; +import Instances from './models/Instances'; import Inventories from './models/Inventories'; import InventoryScripts from './models/InventoryScripts'; import InventorySources from './models/InventorySources'; @@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates'; import Organizations from './models/Organizations'; import ProjectUpdates from './models/ProjectUpdates'; import Projects from './models/Projects'; -import Root from './models/Root'; import Roles from './models/Roles'; +import Root from './models/Root'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; @@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); +const InstancesAPI = new Instances(); const InventoriesAPI = new Inventories(); const InventoryScriptsAPI = new InventoryScripts(); const InventorySourcesAPI = new InventorySources(); @@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates(); const OrganizationsAPI = new Organizations(); const ProjectUpdatesAPI = new ProjectUpdates(); const ProjectsAPI = new Projects(); -const RootAPI = new Root(); const RolesAPI = new Roles(); +const RootAPI = new Root(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); @@ -78,6 +80,7 @@ export { GroupsAPI, HostsAPI, InstanceGroupsAPI, + InstancesAPI, InventoriesAPI, InventoryScriptsAPI, InventorySourcesAPI, @@ -90,8 +93,8 @@ export { OrganizationsAPI, ProjectUpdatesAPI, ProjectsAPI, - RootAPI, RolesAPI, + RootAPI, SchedulesAPI, SystemJobsAPI, TeamsAPI, diff --git a/awx/ui_next/src/api/models/CredentialTypes.js b/awx/ui_next/src/api/models/CredentialTypes.js index dab1676231..39247b5ebc 100644 --- a/awx/ui_next/src/api/models/CredentialTypes.js +++ b/awx/ui_next/src/api/models/CredentialTypes.js @@ -27,6 +27,10 @@ class CredentialTypes extends Base { .concat(nextResults) .filter(type => acceptableKinds.includes(type.kind)); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default CredentialTypes; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 95e954fc0c..13ee1f8a9c 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -25,6 +25,10 @@ class Credentials extends Base { params, }); } + + test(id, data) { + return this.http.post(`${this.baseUrl}${id}/test/`, data); + } } export default Credentials; diff --git a/awx/ui_next/src/api/models/InstanceGroups.js b/awx/ui_next/src/api/models/InstanceGroups.js index 94464e0f42..82704c95d1 100644 --- a/awx/ui_next/src/api/models/InstanceGroups.js +++ b/awx/ui_next/src/api/models/InstanceGroups.js @@ -4,6 +4,37 @@ class InstanceGroups extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/instance_groups/'; + + this.associateInstance = this.associateInstance.bind(this); + this.disassociateInstance = this.disassociateInstance.bind(this); + this.readInstanceOptions = this.readInstanceOptions.bind(this); + this.readInstances = this.readInstances.bind(this); + this.readJobs = this.readJobs.bind(this); + } + + associateInstance(instanceGroupId, instanceId) { + return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, { + id: instanceId, + }); + } + + disassociateInstance(instanceGroupId, instanceId) { + return this.http.post(`${this.baseUrl}${instanceGroupId}/instances/`, { + id: instanceId, + disassociate: true, + }); + } + + readInstances(id, params) { + return this.http.get(`${this.baseUrl}${id}/instances/`, { params }); + } + + readInstanceOptions(id) { + return this.http.options(`${this.baseUrl}${id}/instances/`); + } + + readJobs(id) { + return this.http.get(`${this.baseUrl}${id}/jobs/`); } } diff --git a/awx/ui_next/src/api/models/Instances.js b/awx/ui_next/src/api/models/Instances.js new file mode 100644 index 0000000000..41fa06d5f7 --- /dev/null +++ b/awx/ui_next/src/api/models/Instances.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class Instances extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/instances/'; + } +} + +export default Instances; diff --git a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx index f7a1b2da67..f53ec6b6d4 100644 --- a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx +++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx @@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import useSelected from '../../util/useSelected'; -const QS_CONFIG = getQSConfig('associate', { - page: 1, - page_size: 5, - order_by: 'name', -}); +const QS_CONFIG = (order_by = 'name') => { + return getQSConfig('associate', { + page: 1, + page_size: 5, + order_by, + }); +}; function AssociateModal({ i18n, @@ -23,6 +25,7 @@ function AssociateModal({ fetchRequest, optionsRequest, isModalOpen = false, + displayKey = 'name', }) { const history = useHistory(); const { selected, handleSelect } = useSelected([]); @@ -34,7 +37,10 @@ function AssociateModal({ isLoading, } = useRequest( useCallback(async () => { - const params = parseQueryString(QS_CONFIG, history.location.search); + const params = parseQueryString( + QS_CONFIG(displayKey), + history.location.search + ); const [ { data: { count, results }, @@ -52,7 +58,7 @@ function AssociateModal({ actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), }; - }, [fetchRequest, optionsRequest, history.location.search]), + }, [fetchRequest, optionsRequest, history.location.search, displayKey]), { items: [], itemCount: 0, @@ -112,6 +118,7 @@ function AssociateModal({ ]} > item.name) - .join(', '); + if (verifyCannotDisassociate) { + const itemsUnableToDisassociate = itemsToDisassociate + .filter(cannotDisassociate) + .map(item => item.name) + .join(', '); - if (itemsToDisassociate.some(cannotDisassociate)) { - return ( -
- {i18n._( - t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}` - )} -
- ); + if (itemsToDisassociate.some(cannotDisassociate)) { + return ( +
+ {i18n._( + t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}` + )} +
+ ); + } } + if (itemsToDisassociate.length) { return i18n._(t`Disassociate`); } return i18n._(t`Select a row to disassociate`); } - const isDisabled = - itemsToDisassociate.length === 0 || - itemsToDisassociate.some(cannotDisassociate); + let isDisabled = false; + if (verifyCannotDisassociate) { + isDisabled = + itemsToDisassociate.length === 0 || + itemsToDisassociate.some(cannotDisassociate); + } else { + isDisabled = itemsToDisassociate.length === 0; + } // NOTE: Once PF supports tooltips on disabled elements, // we can delete the extra
around the below. @@ -102,7 +111,7 @@ function DisassociateButton({ {itemsToDisassociate.map(item => ( - {item.name} + {item.hostname ? item.hostname : item.name}
))} diff --git a/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx new file mode 100644 index 0000000000..609938b57d --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Switch, Tooltip } from '@patternfly/react-core'; +import AlertModal from '../AlertModal'; +import ErrorDetail from '../ErrorDetail'; +import useRequest from '../../util/useRequest'; +import { InstancesAPI } from '../../api'; +import { useConfig } from '../../contexts/Config'; + +function InstanceToggle({ + className, + fetchInstances, + instance, + onToggle, + i18n, +}) { + const { me } = useConfig(); + const [isEnabled, setIsEnabled] = useState(instance.enabled); + const [showError, setShowError] = useState(false); + + const { result, isLoading, error, request: toggleInstance } = useRequest( + useCallback(async () => { + await InstancesAPI.update(instance.id, { enabled: !isEnabled }); + await fetchInstances(); + return !isEnabled; + }, [instance, isEnabled, fetchInstances]), + instance.enabled + ); + + useEffect(() => { + if (result !== isEnabled) { + setIsEnabled(result); + if (onToggle) { + onToggle(result); + } + } + }, [result, isEnabled, onToggle]); + + useEffect(() => { + if (error) { + setShowError(true); + } + }, [error]); + + return ( + <> + + + + {showError && error && !isLoading && ( + setShowError(false)} + > + {i18n._(t`Failed to toggle instance.`)} + + + )} + + ); +} + +export default withI18n()(InstanceToggle); diff --git a/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx new file mode 100644 index 0000000000..6ac9dbda7e --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/InstanceToggle.test.jsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { InstancesAPI } from '../../api'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import InstanceToggle from './InstanceToggle'; + +jest.mock('../../api'); + +const mockInstance = { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-05T19:17:18.080033Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 100.0, + jobs_running: 0, + jobs_total: 67, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, +}; + +describe('', () => { + const onToggle = jest.fn(); + const fetchInstances = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should show toggle off', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + expect(InstancesAPI.update).toHaveBeenCalledWith(1, { + enabled: false, + }); + wrapper.update(); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + expect(onToggle).toHaveBeenCalledWith(false); + expect(fetchInstances).toHaveBeenCalledTimes(1); + }); + + test('should show toggle on', async () => { + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(false); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + expect(InstancesAPI.update).toHaveBeenCalledWith(1, { + enabled: true, + }); + wrapper.update(); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + expect(onToggle).toHaveBeenCalledWith(true); + expect(fetchInstances).toHaveBeenCalledTimes(1); + }); + + test('should show error modal', async () => { + InstancesAPI.update.mockImplementation(() => { + throw new Error('nope'); + }); + const wrapper = mountWithContexts( + + ); + expect(wrapper.find('Switch').prop('isChecked')).toEqual(true); + + await act(async () => { + wrapper.find('Switch').invoke('onChange')(); + }); + wrapper.update(); + const modal = wrapper.find('AlertModal'); + expect(modal).toHaveLength(1); + expect(modal.prop('isOpen')).toEqual(true); + + act(() => { + modal.invoke('onClose')(); + }); + wrapper.update(); + expect(wrapper.find('AlertModal')).toHaveLength(0); + }); +}); diff --git a/awx/ui_next/src/components/InstanceToggle/index.js b/awx/ui_next/src/components/InstanceToggle/index.js new file mode 100644 index 0000000000..1f2723c528 --- /dev/null +++ b/awx/ui_next/src/components/InstanceToggle/index.js @@ -0,0 +1 @@ +export { default } from './InstanceToggle'; diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.jsx index b2242d7202..86abce0001 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.jsx @@ -42,6 +42,7 @@ function OptionsList({ renderItemChip, isLoading, i18n, + displayKey, }) { return ( @@ -52,6 +53,7 @@ function OptionsList({ onRemove={item => deselectItem(item)} isReadOnly={readOnly} renderItemChip={renderItemChip} + displayKey={displayKey} /> )} i.id === item.id)} onSelect={() => selectItem(item)} onDeselect={() => deselectItem(item)} @@ -91,22 +93,24 @@ const Item = shape({ url: string, }); OptionsList.propTypes = { - value: arrayOf(Item).isRequired, - options: arrayOf(Item).isRequired, - optionCount: number.isRequired, - searchColumns: SearchColumns, - sortColumns: SortColumns, - multiple: bool, - qsConfig: QSConfig.isRequired, - selectItem: func.isRequired, deselectItem: func.isRequired, + displayKey: string, + multiple: bool, + optionCount: number.isRequired, + options: arrayOf(Item).isRequired, + qsConfig: QSConfig.isRequired, renderItemChip: func, + searchColumns: SearchColumns, + selectItem: func.isRequired, + sortColumns: SortColumns, + value: arrayOf(Item).isRequired, }; OptionsList.defaultProps = { multiple: false, renderItemChip: null, searchColumns: [], sortColumns: [], + displayKey: 'name', }; export default withI18n()(OptionsList); diff --git a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx index 4c5c295976..22f67c5e94 100644 --- a/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/ToolbarAddButton.jsx @@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useKebabifiedMenu } from '../../contexts/Kebabified'; -function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) { +function ToolbarAddButton({ + linkTo, + onClick, + i18n, + isDisabled, + defaultLabel = i18n._(t`Add`), +}) { const { isKebabified } = useKebabifiedMenu(); if (!linkTo && !onClick) { @@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) { 'ToolbarAddButton requires either `linkTo` or `onClick` prop' ); } + if (isKebabified) { return ( - {i18n._(t`Add`)} + {defaultLabel} ); } if (linkTo) { return ( - + ); } return ( - ); } diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bc486bb8b1..5a76a76271 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -1,16 +1,19 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { arrayOf, func, object, shape } from 'prop-types'; -import { Form, FormGroup } from '@patternfly/react-core'; +import { ActionGroup, Button, Form, FormGroup } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../../../components/FormField'; -import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; +import { + FormColumnLayout, + FormFullWidthLayout, +} from '../../../components/FormLayout'; import AnsibleSelect from '../../../components/AnsibleSelect'; import { required } from '../../../util/validators'; import OrganizationLookup from '../../../components/Lookup/OrganizationLookup'; -import { FormColumnLayout } from '../../../components/FormLayout'; import TypeInputsSubForm from './TypeInputsSubForm'; +import ExternalTestModal from './ExternalTestModal'; function CredentialFormFields({ i18n, @@ -139,6 +142,7 @@ function CredentialFormFields({ } function CredentialForm({ + i18n, credential = {}, credentialTypes, inputSources, @@ -147,6 +151,7 @@ function CredentialForm({ submitError, ...rest }) { + const [showExternalTestModal, setShowExternalTestModal] = useState(false); const initialValues = { name: credential.name || '', description: credential.description || '', @@ -205,21 +210,61 @@ function CredentialForm({ }} > {formik => ( -
- - + + + + + + + + {formik?.values?.credential_type && + credentialTypes[formik.values.credential_type]?.kind === + 'external' && ( + + )} + + + + + + {showExternalTestModal && ( + setShowExternalTestModal(false)} /> - - -
- + )} + )} ); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx index d23e5347ee..f4360e75ba 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx @@ -99,6 +99,9 @@ describe('', () => { test('should display form fields on add properly', async () => { addFieldExpects(); }); + test('should hide Test button initially', () => { + expect(wrapper.find('Button[children="Test"]').length).toBe(0); + }); test('should update form values', async () => { // name and description change await act(async () => { @@ -221,6 +224,18 @@ describe('', () => { 'There was an error parsing the file. Please check the file formatting and try again.' ); }); + test('should show Test button when external credential type is selected', async () => { + await act(async () => { + await wrapper + .find('AnsibleSelect[id="credential_type"]') + .invoke('onChange')(null, 21); + }); + wrapper.update(); + expect(wrapper.find('Button[children="Test"]').length).toBe(1); + expect(wrapper.find('Button[children="Test"]').props().isDisabled).toBe( + true + ); + }); test('should call handleCancel when Cancel button is clicked', async () => { expect(onCancel).not.toHaveBeenCalled(); wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx index aafa1c74fe..51c9dfa02d 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx @@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) { > { helpers.setValue(value); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx new file mode 100644 index 0000000000..f1c4a4ae97 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/CredentialPluginTestAlert.jsx @@ -0,0 +1,91 @@ +import React, { useEffect, useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { string, shape } from 'prop-types'; +import { + Alert, + AlertActionCloseButton, + AlertGroup, +} from '@patternfly/react-core'; + +function CredentialPluginTestAlert({ + i18n, + credentialName, + successResponse, + errorResponse, +}) { + const [testMessage, setTestMessage] = useState(''); + const [testVariant, setTestVariant] = useState(false); + + useEffect(() => { + if (errorResponse) { + if (errorResponse?.response?.data?.inputs) { + if (errorResponse.response.data.inputs.startsWith('HTTP')) { + const [ + errorCode, + errorStr, + ] = errorResponse.response.data.inputs.split('\n'); + try { + const errorJSON = JSON.parse(errorStr); + setTestMessage( + `${errorCode}${ + errorJSON?.errors[0] ? `: ${errorJSON.errors[0]}` : '' + }` + ); + } catch { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage(errorResponse.response.data.inputs); + } + } else { + setTestMessage( + i18n._( + t`Something went wrong with the request to test this credential and metadata.` + ) + ); + } + setTestVariant('danger'); + } else if (successResponse) { + setTestMessage(i18n._(t`Test passed`)); + setTestVariant('success'); + } + }, [i18n, successResponse, errorResponse]); + + return ( + + {testMessage && testVariant && ( + { + setTestMessage(null); + setTestVariant(null); + }} + /> + } + title={ + <> + {credentialName} +

{testMessage}

+ + } + variant={testVariant} + /> + )} +
+ ); +} + +CredentialPluginTestAlert.propTypes = { + credentialName: string.isRequired, + successResponse: shape({}), + errorResponse: shape({}), +}; + +CredentialPluginTestAlert.defaultProps = { + successResponse: null, + errorResponse: null, +}; + +export default withI18n()(CredentialPluginTestAlert); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js index 033586567f..3799206eb4 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js +++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialPlugins/index.js @@ -1,2 +1,3 @@ export { default as CredentialPluginSelected } from './CredentialPluginSelected'; export { default as CredentialPluginField } from './CredentialPluginField'; +export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert'; diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx new file mode 100644 index 0000000000..fda8bc4492 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx @@ -0,0 +1,198 @@ +import React, { useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import { func, shape } from 'prop-types'; +import { Formik } from 'formik'; +import { + Button, + Form, + FormGroup, + Modal, + Tooltip, +} from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import AnsibleSelect from '../../../components/AnsibleSelect'; +import FormField from '../../../components/FormField'; +import { FormFullWidthLayout } from '../../../components/FormLayout'; +import { required } from '../../../util/validators'; +import useRequest from '../../../util/useRequest'; +import { CredentialPluginTestAlert } from './CredentialFormFields/CredentialPlugins'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +function ExternalTestModal({ + i18n, + credential, + credentialType, + credentialFormValues, + onClose, +}) { + const { + result: testPluginSuccess, + error: testPluginError, + request: testPluginMetadata, + } = useRequest( + useCallback( + async values => { + const payload = { + inputs: credentialType.inputs.fields.reduce( + (filteredInputs, field) => { + filteredInputs[field.id] = credentialFormValues.inputs[field.id]; + return filteredInputs; + }, + {} + ), + metadata: values, + }; + + if (credential && credential.credential_type === credentialType.id) { + return CredentialsAPI.test(credential.id, payload); + } + return CredentialTypesAPI.test(credentialType.id, payload); + }, + [ + credential, + credentialType.id, + credentialType.inputs.fields, + credentialFormValues.inputs, + ] + ), + null + ); + + const handleTest = async values => { + await testPluginMetadata(values); + }; + + return ( + <> + { + if (field.type === 'string' && field.choices) { + initialValues[field.id] = field.default || field.choices[0]; + } else { + initialValues[field.id] = ''; + } + return initialValues; + }, + {} + )} + onSubmit={values => handleTest(values)} + > + {({ handleSubmit, setFieldValue }) => ( + onClose()} + variant="small" + actions={[ + , + , + ]} + > +
+ + {credentialType.inputs.metadata.map(field => { + const isRequired = credentialType.inputs?.required.includes( + field.id + ); + if (field.type === 'string') { + if (field.choices) { + return ( + + + + ) + } + isRequired={isRequired} + > + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + setFieldValue(field.id, value); + }} + validate={isRequired ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+
+ )} +
+ + + ); +} + +ExternalTestModal.proptype = { + credential: shape({}), + credentialType: shape({}).isRequired, + credentialFormValues: shape({}).isRequired, + onClose: func.isRequired, +}; + +ExternalTestModal.defaultProps = { + credential: null, +}; + +export default withI18n()(ExternalTestModal); diff --git a/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx new file mode 100644 index 0000000000..91677795aa --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/ExternalTestModal.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import ExternalTestModal from './ExternalTestModal'; +import credentialTypesArr from './data.credentialTypes.json'; + +jest.mock('../../../api/models/Credentials'); +jest.mock('../../../api/models/CredentialTypes'); + +const credentialType = credentialTypesArr.find( + credType => credType.namespace === 'hashivault_kv' +); + +const credentialFormValues = { + name: 'Foobar', + credential_type: credentialType.id, + inputs: { + api_version: 'v2', + token: '$encrypted$', + url: 'http://hashivault:8200', + }, +}; + +const credential = { + id: 1, + name: 'A credential', + credential_type: credentialType.id, +}; + +describe('', () => { + let wrapper; + afterEach(() => wrapper.unmount()); + test('should display metadata fields correctly', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('FormField').length).toBe(5); + expect(wrapper.find('input#credential-secret_backend').length).toBe(1); + expect(wrapper.find('input#credential-secret_path').length).toBe(1); + expect(wrapper.find('input#credential-auth_path').length).toBe(1); + expect(wrapper.find('input#credential-secret_key').length).toBe(1); + expect(wrapper.find('input#credential-secret_version').length).toBe(1); + }); + test('should make the test request correctly when testing an existing credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialsAPI.test).toHaveBeenCalledWith(1, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should make the test request correctly when testing a new credential', async () => { + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + expect(CredentialTypesAPI.test).toHaveBeenCalledWith(21, { + inputs: { + api_version: 'v2', + cacert: undefined, + role_id: undefined, + secret_id: undefined, + token: '$encrypted$', + url: 'http://hashivault:8200', + }, + metadata: { + auth_path: '', + secret_backend: '', + secret_key: 'password', + secret_path: '/secret/foo/bar/baz', + secret_version: '', + }, + }); + }); + test('should display the alert after a successful test', async () => { + CredentialTypesAPI.test.mockResolvedValue({}); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('success'); + }); + test('should display the alert after a failed test', async () => { + CredentialTypesAPI.test.mockRejectedValue({ + inputs: `HTTP 404 + {"errors":["no handler for route '/secret/foo/bar/baz'"]} + `, + }); + wrapper = mountWithContexts( + + ); + await act(async () => { + wrapper.find('input#credential-secret_path').simulate('change', { + target: { value: '/secret/foo/bar/baz', name: 'secret_path' }, + }); + wrapper.find('input#credential-secret_key').simulate('change', { + target: { value: 'password', name: 'secret_key' }, + }); + }); + wrapper.update(); + await act(async () => { + wrapper.find('Button[children="Run"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find('Alert').length).toBe(1); + expect(wrapper.find('Alert').props().variant).toBe('danger'); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/index.js b/awx/ui_next/src/screens/Credential/shared/index.js index ad01a03c29..28dda5128a 100644 --- a/awx/ui_next/src/screens/Credential/shared/index.js +++ b/awx/ui_next/src/screens/Credential/shared/index.js @@ -1,2 +1,3 @@ export { default as mockCredentials } from './data.credentials.json'; export { default as mockCredentialType } from './data.credential_type.json'; +export { default as ExternalTestModal } from './ExternalTestModal'; diff --git a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx index 4051adee05..b212f0b76e 100644 --- a/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx +++ b/awx/ui_next/src/screens/CredentialType/CredentialTypeList/CredentialTypeList.jsx @@ -32,7 +32,13 @@ function CredentialTypeList({ i18n }) { error: contentError, isLoading, request: fetchCredentialTypes, - result: { credentialTypes, credentialTypesCount, actions }, + result: { + credentialTypes, + credentialTypesCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -46,12 +52,20 @@ function CredentialTypeList({ i18n }) { credentialTypes: response.data.results, credentialTypesCount: response.data.count, actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), }; }, [location]), { credentialTypes: [], credentialTypesCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -100,6 +114,8 @@ function CredentialTypeList({ i18n }) { pluralizedItemName={i18n._(t`Credential Types`)} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( - + { - return ( - (item.policy_instance_minimum || item.policy_instance_percentage) && - item.capacity - ); - }; - const verifyIsIsolated = item => { if (item.is_isolated) { return ( @@ -89,10 +82,12 @@ function InstanceGroupDetails({ instanceGroup, i18n }) { dataCy="instance-group-policy-instance-percentage" content={`${instanceGroup.policy_instance_percentage} %`} /> - {isAvailable(instanceGroup) ? ( + {instanceGroup.capacity ? ( ) : ( diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx index b366e9eb25..794724ef6b 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupList.jsx @@ -49,7 +49,13 @@ function InstanceGroupList({ i18n }) { error: contentError, isLoading, request: fetchInstanceGroups, - result: { instanceGroups, instanceGroupsCount, actions }, + result: { + instanceGroups, + instanceGroupsCount, + actions, + relatedSearchableKeys, + searchableKeys, + }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -63,12 +69,20 @@ function InstanceGroupList({ i18n }) { instanceGroups: response.data.results, instanceGroupsCount: response.data.count, actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), }; }, [location]), { instanceGroups: [], instanceGroupsCount: 0, actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], } ); @@ -171,6 +185,8 @@ function InstanceGroupList({ i18n }) { pluralizedItemName={pluralizedItemName} qsConfig={QS_CONFIG} onRowClick={handleSelect} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { +describe('', () => { let wrapper; test('should have data fetched and render 3 rows', async () => { diff --git a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx index f531f8b182..9ea19a5dd2 100644 --- a/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx +++ b/awx/ui_next/src/screens/InstanceGroup/InstanceGroupList/InstanceGroupListItem.jsx @@ -60,23 +60,16 @@ function InstanceGroupListItem({ }) { const labelId = `check-action-${instanceGroup.id}`; - const isAvailable = item => { - return ( - (item.policy_instance_minimum || item.policy_instance_percentage) && - item.capacity - ); - }; - const isContainerGroup = item => { return item.is_containerized; }; function usedCapacity(item) { if (!isContainerGroup(item)) { - if (isAvailable(item)) { + if (item.capacity) { return ( { + const params = parseQueryString(QS_CONFIG, location.search); + const [response, responseActions] = await Promise.all([ + InstanceGroupsAPI.readInstances(instanceGroupId, params), + InstanceGroupsAPI.readInstanceOptions(instanceGroupId), + ]); + return { + instances: response.data.results, + count: response.data.count, + actions: responseActions.data.actions, + relatedSearchableKeys: ( + responseActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + searchableKeys: Object.keys( + responseActions.data.actions?.GET || {} + ).filter(key => responseActions.data.actions?.GET[key].filterable), + }; + }, [location.search, instanceGroupId]), + { + instances: [], + count: 0, + actions: {}, + relatedSearchableKeys: [], + searchableKeys: [], + } + ); + + const { selected, isAllSelected, handleSelect, setSelected } = useSelected( + instances + ); + + useEffect(() => { + fetchInstances(); + }, [fetchInstances]); + + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateInstances, + deletionError: disassociateError, + } = useDeleteItems( + useCallback(async () => { + return Promise.all( + selected.map(instance => + InstanceGroupsAPI.disassociateInstance(instanceGroupId, instance.id) + ) + ); + }, [instanceGroupId, selected]), + { + qsConfig: QS_CONFIG, + allItemsSelected: isAllSelected, + fetchItems: fetchInstances, + } + ); + + const { request: handleAssociate, error: associateError } = useRequest( + useCallback( + async instancesToAssociate => { + await Promise.all( + instancesToAssociate.map(instance => + InstanceGroupsAPI.associateInstance(instanceGroupId, instance.id) + ) + ); + fetchInstances(); + }, + [instanceGroupId, fetchInstances] + ) + ); + + const handleDisassociate = async () => { + await disassociateInstances(); + setSelected([]); + }; + + const { error, dismissError } = useDismissableError( + associateError || disassociateError + ); + + const canAdd = + actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + + const fetchInstancesToAssociate = useCallback( + params => { + return InstancesAPI.read( + mergeParams(params, { not__rampart_groups__id: instanceGroupId }) + ); + }, + [instanceGroupId] + ); + + const readInstancesOptions = () => + InstanceGroupsAPI.readInstanceOptions(instanceGroupId); + + return ( + <> + ( + + setSelected(isSelected ? [...instances] : []) + } + qsConfig={QS_CONFIG} + additionalControls={[ + ...(canAdd + ? [ + setIsModalOpen(true)} + defaultLabel={i18n._(t`Associate`)} + />, + ] + : []), + , + ]} + emptyStateControls={ + canAdd ? ( + setIsModalOpen(true)} + /> + ) : null + } + /> + )} + renderItem={instance => ( + handleSelect(instance)} + isSelected={selected.some(row => row.id === instance.id)} + fetchInstances={fetchInstances} + /> + )} + /> + {isModalOpen && ( + setIsModalOpen(false)} + title={i18n._(t`Select Instances`)} + optionsRequest={readInstancesOptions} + displayKey="hostname" + /> + )} + {error && ( + + {associateError + ? i18n._(t`Failed to associate.`) + : i18n._(t`Failed to disassociate one or more instances.`)} + + + )} + + ); +} + +export default withI18n()(InstanceList); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx new file mode 100644 index 0000000000..d2c7edf32d --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.test.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { InstanceGroupsAPI } from '../../../api'; + +import InstanceList from './InstanceList'; + +jest.mock('../../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + instanceGroupId: 2, + }), +})); + +const instances = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, + { + id: 2, + type: 'instance', + url: '/api/v2/instances/2/', + related: { + jobs: '/api/v2/instances/2/jobs/', + instance_groups: '/api/v2/instances/2/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'foo', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: false, + }, + { + id: 3, + type: 'instance', + url: '/api/v2/instances/3/', + related: { + jobs: '/api/v2/instances/3/jobs/', + instance_groups: '/api/v2/instances/3/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'bar', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: false, + managed_by_policy: true, + }, +]; + +const options = { data: { actions: { POST: true } } }; + +describe('', () => { + let wrapper; + + beforeEach(async () => { + InstanceGroupsAPI.readInstances.mockResolvedValue({ + data: { + count: instances.length, + results: instances, + }, + }); + InstanceGroupsAPI.readInstanceOptions.mockResolvedValue(options); + const history = createMemoryHistory({ + initialEntries: ['/instance_groups/1/instances'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { history, route: { location: history.location } }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + + test('should have data fetched', () => { + expect(wrapper.find('InstanceList').length).toBe(1); + }); + + test('should fetch instances from the api and render them in the list', () => { + expect(InstanceGroupsAPI.readInstances).toHaveBeenCalled(); + expect(InstanceGroupsAPI.readInstanceOptions).toHaveBeenCalled(); + expect(wrapper.find('InstanceListItem').length).toBe(3); + }); + + test('should show associate group modal when adding an existing group', () => { + wrapper.find('ToolbarAddButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(1); + wrapper.find('ModalBoxCloseButton').simulate('click'); + expect(wrapper.find('AssociateModal').length).toBe(0); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx new file mode 100644 index 0000000000..a26012a738 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.jsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { bool, func } from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import styled from 'styled-components'; +import 'styled-components/macro'; +import { + Badge as PFBadge, + Progress, + ProgressMeasureLocation, + ProgressSize, + DataListAction, + DataListCheck, + DataListItem, + DataListItemRow, + DataListItemCells, +} from '@patternfly/react-core'; + +import _DataListCell from '../../../components/DataListCell'; +import InstanceToggle from '../../../components/InstanceToggle'; +import { Instance } from '../../../types'; + +const Unavailable = styled.span` + color: var(--pf-global--danger-color--200); +`; + +const DataListCell = styled(_DataListCell)` + white-space: nowrap; +`; + +const Badge = styled(PFBadge)` + margin-left: 8px; +`; + +const ListGroup = styled.span` + margin-left: 12px; + + &:first-of-type { + margin-left: 0; + } +`; + +function InstanceListItem({ + instance, + isSelected, + onSelect, + fetchInstances, + i18n, +}) { + const labelId = `check-action-${instance.id}`; + + function usedCapacity(item) { + if (item.enabled) { + return ( + + ); + } + return {i18n._(t`Unavailable`)}; + } + + return ( + + + + + + {instance.hostname} + , + + {i18n._(t`Type`)} + + {instance.managed_by_policy + ? i18n._(t`Auto`) + : i18n._(t`Manual`)} + + , + + + {i18n._(t`Running jobs`)} + {instance.jobs_running} + + + {i18n._(t`Total jobs`)} + {instance.jobs_total} + + , + + {usedCapacity(instance)} + , + ]} + /> + + + + + + ); +} +InstanceListItem.prototype = { + instance: Instance.isRequired, + isSelected: bool.isRequired, + onSelect: func.isRequired, +}; + +export default withI18n()(InstanceListItem); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx new file mode 100644 index 0000000000..5e3a138f90 --- /dev/null +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/InstanceListItem.test.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import InstanceListItem from './InstanceListItem'; + +const instance = [ + { + id: 1, + type: 'instance', + url: '/api/v2/instances/1/', + related: { + jobs: '/api/v2/instances/1/jobs/', + instance_groups: '/api/v2/instances/1/instance_groups/', + }, + uuid: '00000000-0000-0000-0000-000000000000', + hostname: 'awx', + created: '2020-07-14T19:03:49.000054Z', + modified: '2020-08-12T20:08:02.836748Z', + capacity_adjustment: '0.40', + version: '13.0.0', + capacity: 10, + consumed_capacity: 0, + percent_capacity_remaining: 60.0, + jobs_running: 0, + jobs_total: 68, + cpu: 6, + memory: 2087469056, + cpu_capacity: 24, + mem_capacity: 1, + enabled: true, + managed_by_policy: true, + }, +]; + +describe('', () => { + let wrapper; + + test('should mount successfully', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('InstanceListItem').length).toBe(1); + }); + + test('should render the proper data instance', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect( + wrapper.find('PFDataListCell[aria-label="instance host name"]').text() + ).toBe('awx'); + expect(wrapper.find('Progress').prop('value')).toBe(40); + expect( + wrapper.find('PFDataListCell[aria-label="instance type"]').text() + ).toBe('TypeAuto'); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(false); + }); + + test('should be checked', async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + /> + ); + }); + expect(wrapper.find('input#instances-1').prop('checked')).toBe(true); + }); + + test('should display instance toggle', () => { + expect(wrapper.find('InstanceToggle').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx b/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx deleted file mode 100644 index b41760edd5..0000000000 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/Instances.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { Card, PageSection } from '@patternfly/react-core'; - -function Instances() { - return ( - - -
Instances
-
-
- ); -} - -export default Instances; diff --git a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js index b018ebb049..2567e3c8e7 100644 --- a/awx/ui_next/src/screens/InstanceGroup/Instances/index.js +++ b/awx/ui_next/src/screens/InstanceGroup/Instances/index.js @@ -1 +1,2 @@ -export { default } from './Instances'; +export { default as InstanceList } from './InstanceList'; +export { default as InstanceListItem } from './InstanceListItem'; diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx index eb0be40346..10a34bedc7 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateListItem.jsx @@ -15,6 +15,7 @@ import { withI18n } from '@lingui/react'; import { ExclamationTriangleIcon, PencilAltIcon, + ProjectDiagramIcon, RocketIcon, } from '@patternfly/react-icons'; import styled from 'styled-components'; @@ -32,7 +33,7 @@ const DataListAction = styled(_DataListAction)` align-items: center; display: grid; grid-gap: 16px; - grid-template-columns: repeat(3, 40px); + grid-template-columns: repeat(4, 40px); `; function TemplateListItem({ @@ -104,6 +105,20 @@ function TemplateListItem({ ]} /> + {template.type === 'workflow_job_template' && ( + + + + )} {template.summary_fields.user_capabilities.start && ( @@ -111,7 +126,7 @@ function TemplateListItem({