mirror of
https://github.com/ansible/awx.git
synced 2026-03-17 00:47:29 -02:30
@@ -180,7 +180,8 @@ def ship(path):
|
|||||||
auth=(rh_user, rh_password),
|
auth=(rh_user, rh_password),
|
||||||
headers=s.headers,
|
headers=s.headers,
|
||||||
timeout=(31, 31))
|
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,
|
return logger.exception('Upload failed with status {}, {}'.format(response.status_code,
|
||||||
response.text))
|
response.text))
|
||||||
run_now = now()
|
run_now = now()
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ def kv_backend(**kwargs):
|
|||||||
|
|
||||||
sess = requests.Session()
|
sess = requests.Session()
|
||||||
sess.headers['Authorization'] = 'Bearer {}'.format(token)
|
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
|
sess.headers['X-Vault-Token'] = token
|
||||||
|
|
||||||
if api_version == 'v2':
|
if api_version == 'v2':
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from django.core.management.base import BaseCommand, CommandError
|
|||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
"""
|
"""
|
||||||
Internal tower command.
|
Internal tower command.
|
||||||
Regsiter this instance with the database for HA tracking.
|
Register this instance with the database for HA tracking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
help = (
|
help = (
|
||||||
|
|||||||
@@ -2169,7 +2169,7 @@ class RunProjectUpdate(BaseTask):
|
|||||||
self._write_extra_vars_file(private_data_dir, extra_vars)
|
self._write_extra_vars_file(private_data_dir, extra_vars)
|
||||||
|
|
||||||
def build_cwd(self, project_update, private_data_dir):
|
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):
|
def build_playbook_path_relative_to_cwd(self, project_update, private_data_dir):
|
||||||
return os.path.join('project_update.yml')
|
return os.path.join('project_update.yml')
|
||||||
@@ -2310,6 +2310,12 @@ class RunProjectUpdate(BaseTask):
|
|||||||
shutil.rmtree(stage_path)
|
shutil.rmtree(stage_path)
|
||||||
os.makedirs(stage_path) # presence of empty cache indicates lack of roles or collections
|
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
|
@staticmethod
|
||||||
def clear_project_cache(cache_dir, keep_value):
|
def clear_project_cache(cache_dir, keep_value):
|
||||||
if os.path.isdir(cache_dir):
|
if os.path.isdir(cache_dir):
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Credentials from './models/Credentials';
|
|||||||
import Groups from './models/Groups';
|
import Groups from './models/Groups';
|
||||||
import Hosts from './models/Hosts';
|
import Hosts from './models/Hosts';
|
||||||
import InstanceGroups from './models/InstanceGroups';
|
import InstanceGroups from './models/InstanceGroups';
|
||||||
|
import Instances from './models/Instances';
|
||||||
import Inventories from './models/Inventories';
|
import Inventories from './models/Inventories';
|
||||||
import InventoryScripts from './models/InventoryScripts';
|
import InventoryScripts from './models/InventoryScripts';
|
||||||
import InventorySources from './models/InventorySources';
|
import InventorySources from './models/InventorySources';
|
||||||
@@ -19,8 +20,8 @@ import NotificationTemplates from './models/NotificationTemplates';
|
|||||||
import Organizations from './models/Organizations';
|
import Organizations from './models/Organizations';
|
||||||
import ProjectUpdates from './models/ProjectUpdates';
|
import ProjectUpdates from './models/ProjectUpdates';
|
||||||
import Projects from './models/Projects';
|
import Projects from './models/Projects';
|
||||||
import Root from './models/Root';
|
|
||||||
import Roles from './models/Roles';
|
import Roles from './models/Roles';
|
||||||
|
import Root from './models/Root';
|
||||||
import Schedules from './models/Schedules';
|
import Schedules from './models/Schedules';
|
||||||
import SystemJobs from './models/SystemJobs';
|
import SystemJobs from './models/SystemJobs';
|
||||||
import Teams from './models/Teams';
|
import Teams from './models/Teams';
|
||||||
@@ -42,6 +43,7 @@ const CredentialsAPI = new Credentials();
|
|||||||
const GroupsAPI = new Groups();
|
const GroupsAPI = new Groups();
|
||||||
const HostsAPI = new Hosts();
|
const HostsAPI = new Hosts();
|
||||||
const InstanceGroupsAPI = new InstanceGroups();
|
const InstanceGroupsAPI = new InstanceGroups();
|
||||||
|
const InstancesAPI = new Instances();
|
||||||
const InventoriesAPI = new Inventories();
|
const InventoriesAPI = new Inventories();
|
||||||
const InventoryScriptsAPI = new InventoryScripts();
|
const InventoryScriptsAPI = new InventoryScripts();
|
||||||
const InventorySourcesAPI = new InventorySources();
|
const InventorySourcesAPI = new InventorySources();
|
||||||
@@ -54,8 +56,8 @@ const NotificationTemplatesAPI = new NotificationTemplates();
|
|||||||
const OrganizationsAPI = new Organizations();
|
const OrganizationsAPI = new Organizations();
|
||||||
const ProjectUpdatesAPI = new ProjectUpdates();
|
const ProjectUpdatesAPI = new ProjectUpdates();
|
||||||
const ProjectsAPI = new Projects();
|
const ProjectsAPI = new Projects();
|
||||||
const RootAPI = new Root();
|
|
||||||
const RolesAPI = new Roles();
|
const RolesAPI = new Roles();
|
||||||
|
const RootAPI = new Root();
|
||||||
const SchedulesAPI = new Schedules();
|
const SchedulesAPI = new Schedules();
|
||||||
const SystemJobsAPI = new SystemJobs();
|
const SystemJobsAPI = new SystemJobs();
|
||||||
const TeamsAPI = new Teams();
|
const TeamsAPI = new Teams();
|
||||||
@@ -78,6 +80,7 @@ export {
|
|||||||
GroupsAPI,
|
GroupsAPI,
|
||||||
HostsAPI,
|
HostsAPI,
|
||||||
InstanceGroupsAPI,
|
InstanceGroupsAPI,
|
||||||
|
InstancesAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
InventoryScriptsAPI,
|
InventoryScriptsAPI,
|
||||||
InventorySourcesAPI,
|
InventorySourcesAPI,
|
||||||
@@ -90,8 +93,8 @@ export {
|
|||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
ProjectUpdatesAPI,
|
ProjectUpdatesAPI,
|
||||||
ProjectsAPI,
|
ProjectsAPI,
|
||||||
RootAPI,
|
|
||||||
RolesAPI,
|
RolesAPI,
|
||||||
|
RootAPI,
|
||||||
SchedulesAPI,
|
SchedulesAPI,
|
||||||
SystemJobsAPI,
|
SystemJobsAPI,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class CredentialTypes extends Base {
|
|||||||
.concat(nextResults)
|
.concat(nextResults)
|
||||||
.filter(type => acceptableKinds.includes(type.kind));
|
.filter(type => acceptableKinds.includes(type.kind));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(id, data) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/test/`, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CredentialTypes;
|
export default CredentialTypes;
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class Credentials extends Base {
|
|||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(id, data) {
|
||||||
|
return this.http.post(`${this.baseUrl}${id}/test/`, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Credentials;
|
export default Credentials;
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ class InstanceGroups extends Base {
|
|||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/instance_groups/';
|
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/`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
awx/ui_next/src/api/models/Instances.js
Normal file
10
awx/ui_next/src/api/models/Instances.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class Instances extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = '/api/v2/instances/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Instances;
|
||||||
@@ -8,11 +8,13 @@ import useRequest from '../../util/useRequest';
|
|||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import useSelected from '../../util/useSelected';
|
import useSelected from '../../util/useSelected';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('associate', {
|
const QS_CONFIG = (order_by = 'name') => {
|
||||||
page: 1,
|
return getQSConfig('associate', {
|
||||||
page_size: 5,
|
page: 1,
|
||||||
order_by: 'name',
|
page_size: 5,
|
||||||
});
|
order_by,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
function AssociateModal({
|
function AssociateModal({
|
||||||
i18n,
|
i18n,
|
||||||
@@ -23,6 +25,7 @@ function AssociateModal({
|
|||||||
fetchRequest,
|
fetchRequest,
|
||||||
optionsRequest,
|
optionsRequest,
|
||||||
isModalOpen = false,
|
isModalOpen = false,
|
||||||
|
displayKey = 'name',
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { selected, handleSelect } = useSelected([]);
|
const { selected, handleSelect } = useSelected([]);
|
||||||
@@ -34,7 +37,10 @@ function AssociateModal({
|
|||||||
isLoading,
|
isLoading,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(
|
||||||
|
QS_CONFIG(displayKey),
|
||||||
|
history.location.search
|
||||||
|
);
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
data: { count, results },
|
data: { count, results },
|
||||||
@@ -52,7 +58,7 @@ function AssociateModal({
|
|||||||
actionsResponse.data.actions?.GET || {}
|
actionsResponse.data.actions?.GET || {}
|
||||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [fetchRequest, optionsRequest, history.location.search]),
|
}, [fetchRequest, optionsRequest, history.location.search, displayKey]),
|
||||||
{
|
{
|
||||||
items: [],
|
items: [],
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
@@ -112,6 +118,7 @@ function AssociateModal({
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
displayKey={displayKey}
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
deselectItem={handleSelect}
|
deselectItem={handleSelect}
|
||||||
header={header}
|
header={header}
|
||||||
@@ -119,14 +126,14 @@ function AssociateModal({
|
|||||||
multiple
|
multiple
|
||||||
optionCount={itemCount}
|
optionCount={itemCount}
|
||||||
options={items}
|
options={items}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG(displayKey)}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
selectItem={handleSelect}
|
selectItem={handleSelect}
|
||||||
value={selected}
|
value={selected}
|
||||||
searchColumns={[
|
searchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
key: 'name__icontains',
|
key: `${displayKey}__icontains`,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,7 +148,7 @@ function AssociateModal({
|
|||||||
sortColumns={[
|
sortColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
key: 'name',
|
key: `${displayKey}`,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
searchableKeys={searchableKeys}
|
searchableKeys={searchableKeys}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function DisassociateButton({
|
|||||||
modalNote = '',
|
modalNote = '',
|
||||||
modalTitle = i18n._(t`Disassociate?`),
|
modalTitle = i18n._(t`Disassociate?`),
|
||||||
onDisassociate,
|
onDisassociate,
|
||||||
|
verifyCannotDisassociate = true,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -25,33 +26,41 @@ function DisassociateButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cannotDisassociate(item) {
|
function cannotDisassociate(item) {
|
||||||
return !item.summary_fields.user_capabilities.delete;
|
return !item.summary_fields?.user_capabilities?.delete;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTooltip() {
|
function renderTooltip() {
|
||||||
const itemsUnableToDisassociate = itemsToDisassociate
|
if (verifyCannotDisassociate) {
|
||||||
.filter(cannotDisassociate)
|
const itemsUnableToDisassociate = itemsToDisassociate
|
||||||
.map(item => item.name)
|
.filter(cannotDisassociate)
|
||||||
.join(', ');
|
.map(item => item.name)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
if (itemsToDisassociate.some(cannotDisassociate)) {
|
if (itemsToDisassociate.some(cannotDisassociate)) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{i18n._(
|
{i18n._(
|
||||||
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
t`You do not have permission to disassociate the following: ${itemsUnableToDisassociate}`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemsToDisassociate.length) {
|
if (itemsToDisassociate.length) {
|
||||||
return i18n._(t`Disassociate`);
|
return i18n._(t`Disassociate`);
|
||||||
}
|
}
|
||||||
return i18n._(t`Select a row to disassociate`);
|
return i18n._(t`Select a row to disassociate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDisabled =
|
let isDisabled = false;
|
||||||
itemsToDisassociate.length === 0 ||
|
if (verifyCannotDisassociate) {
|
||||||
itemsToDisassociate.some(cannotDisassociate);
|
isDisabled =
|
||||||
|
itemsToDisassociate.length === 0 ||
|
||||||
|
itemsToDisassociate.some(cannotDisassociate);
|
||||||
|
} else {
|
||||||
|
isDisabled = itemsToDisassociate.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Once PF supports tooltips on disabled elements,
|
// NOTE: Once PF supports tooltips on disabled elements,
|
||||||
// we can delete the extra <div> around the <DeleteButton> below.
|
// we can delete the extra <div> around the <DeleteButton> below.
|
||||||
@@ -102,7 +111,7 @@ function DisassociateButton({
|
|||||||
|
|
||||||
{itemsToDisassociate.map(item => (
|
{itemsToDisassociate.map(item => (
|
||||||
<span key={item.id}>
|
<span key={item.id}>
|
||||||
<strong>{item.name}</strong>
|
<strong>{item.hostname ? item.hostname : item.name}</strong>
|
||||||
<br />
|
<br />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
81
awx/ui_next/src/components/InstanceToggle/InstanceToggle.jsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
content={i18n._(
|
||||||
|
t`Set the instance online or offline. If offline, jobs will not be assigned to this instance.`
|
||||||
|
)}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
className={className}
|
||||||
|
css="display: inline-flex;"
|
||||||
|
id={`host-${instance.id}-toggle`}
|
||||||
|
label={i18n._(t`On`)}
|
||||||
|
labelOff={i18n._(t`Off`)}
|
||||||
|
isChecked={isEnabled}
|
||||||
|
isDisabled={isLoading || !me.is_superuser}
|
||||||
|
onChange={toggleInstance}
|
||||||
|
aria-label={i18n._(t`Toggle instance`)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{showError && error && !isLoading && (
|
||||||
|
<AlertModal
|
||||||
|
variant="error"
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
isOpen={error && !isLoading}
|
||||||
|
onClose={() => setShowError(false)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Failed to toggle instance.`)}
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(InstanceToggle);
|
||||||
@@ -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('<InstanceToggle>', () => {
|
||||||
|
const onToggle = jest.fn();
|
||||||
|
const fetchInstances = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show toggle off', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<InstanceToggle
|
||||||
|
instance={mockInstance}
|
||||||
|
fetchInstances={fetchInstances}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InstanceToggle
|
||||||
|
instance={{
|
||||||
|
...mockInstance,
|
||||||
|
enabled: false,
|
||||||
|
}}
|
||||||
|
onToggle={onToggle}
|
||||||
|
fetchInstances={fetchInstances}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<InstanceToggle instance={mockInstance} />
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
1
awx/ui_next/src/components/InstanceToggle/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './InstanceToggle';
|
||||||
@@ -42,6 +42,7 @@ function OptionsList({
|
|||||||
renderItemChip,
|
renderItemChip,
|
||||||
isLoading,
|
isLoading,
|
||||||
i18n,
|
i18n,
|
||||||
|
displayKey,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ModalList>
|
<ModalList>
|
||||||
@@ -52,6 +53,7 @@ function OptionsList({
|
|||||||
onRemove={item => deselectItem(item)}
|
onRemove={item => deselectItem(item)}
|
||||||
isReadOnly={readOnly}
|
isReadOnly={readOnly}
|
||||||
renderItemChip={renderItemChip}
|
renderItemChip={renderItemChip}
|
||||||
|
displayKey={displayKey}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
@@ -70,8 +72,8 @@ function OptionsList({
|
|||||||
<CheckboxListItem
|
<CheckboxListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
name={multiple ? item.name : name}
|
name={multiple ? item[displayKey] : name}
|
||||||
label={item.name}
|
label={item[displayKey]}
|
||||||
isSelected={value.some(i => i.id === item.id)}
|
isSelected={value.some(i => i.id === item.id)}
|
||||||
onSelect={() => selectItem(item)}
|
onSelect={() => selectItem(item)}
|
||||||
onDeselect={() => deselectItem(item)}
|
onDeselect={() => deselectItem(item)}
|
||||||
@@ -91,22 +93,24 @@ const Item = shape({
|
|||||||
url: string,
|
url: string,
|
||||||
});
|
});
|
||||||
OptionsList.propTypes = {
|
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,
|
deselectItem: func.isRequired,
|
||||||
|
displayKey: string,
|
||||||
|
multiple: bool,
|
||||||
|
optionCount: number.isRequired,
|
||||||
|
options: arrayOf(Item).isRequired,
|
||||||
|
qsConfig: QSConfig.isRequired,
|
||||||
renderItemChip: func,
|
renderItemChip: func,
|
||||||
|
searchColumns: SearchColumns,
|
||||||
|
selectItem: func.isRequired,
|
||||||
|
sortColumns: SortColumns,
|
||||||
|
value: arrayOf(Item).isRequired,
|
||||||
};
|
};
|
||||||
OptionsList.defaultProps = {
|
OptionsList.defaultProps = {
|
||||||
multiple: false,
|
multiple: false,
|
||||||
renderItemChip: null,
|
renderItemChip: null,
|
||||||
searchColumns: [],
|
searchColumns: [],
|
||||||
sortColumns: [],
|
sortColumns: [],
|
||||||
|
displayKey: 'name',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(OptionsList);
|
export default withI18n()(OptionsList);
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
import { useKebabifiedMenu } from '../../contexts/Kebabified';
|
||||||
|
|
||||||
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
function ToolbarAddButton({
|
||||||
|
linkTo,
|
||||||
|
onClick,
|
||||||
|
i18n,
|
||||||
|
isDisabled,
|
||||||
|
defaultLabel = i18n._(t`Add`),
|
||||||
|
}) {
|
||||||
const { isKebabified } = useKebabifiedMenu();
|
const { isKebabified } = useKebabifiedMenu();
|
||||||
|
|
||||||
if (!linkTo && !onClick) {
|
if (!linkTo && !onClick) {
|
||||||
@@ -14,6 +20,7 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
|||||||
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
|
'ToolbarAddButton requires either `linkTo` or `onClick` prop'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isKebabified) {
|
if (isKebabified) {
|
||||||
return (
|
return (
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@@ -23,28 +30,28 @@ function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
|
|||||||
to={linkTo}
|
to={linkTo}
|
||||||
onClick={!onClick ? undefined : onClick}
|
onClick={!onClick ? undefined : onClick}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (linkTo) {
|
if (linkTo) {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={i18n._(t`Add`)} position="top">
|
<Tooltip content={defaultLabel} position="top">
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
component={Link}
|
component={Link}
|
||||||
to={linkTo}
|
to={linkTo}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
aria-label={i18n._(t`Add`)}
|
aria-label={defaultLabel}
|
||||||
>
|
>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button variant="primary" aria-label={i18n._(t`Add`)} onClick={onClick}>
|
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
|
||||||
{i18n._(t`Add`)}
|
{defaultLabel}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Formik, useField } from 'formik';
|
import { Formik, useField } from 'formik';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { arrayOf, func, object, shape } from 'prop-types';
|
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 FormField, { FormSubmitError } from '../../../components/FormField';
|
||||||
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
|
import {
|
||||||
|
FormColumnLayout,
|
||||||
|
FormFullWidthLayout,
|
||||||
|
} from '../../../components/FormLayout';
|
||||||
import AnsibleSelect from '../../../components/AnsibleSelect';
|
import AnsibleSelect from '../../../components/AnsibleSelect';
|
||||||
import { required } from '../../../util/validators';
|
import { required } from '../../../util/validators';
|
||||||
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
||||||
import { FormColumnLayout } from '../../../components/FormLayout';
|
|
||||||
import TypeInputsSubForm from './TypeInputsSubForm';
|
import TypeInputsSubForm from './TypeInputsSubForm';
|
||||||
|
import ExternalTestModal from './ExternalTestModal';
|
||||||
|
|
||||||
function CredentialFormFields({
|
function CredentialFormFields({
|
||||||
i18n,
|
i18n,
|
||||||
@@ -139,6 +142,7 @@ function CredentialFormFields({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CredentialForm({
|
function CredentialForm({
|
||||||
|
i18n,
|
||||||
credential = {},
|
credential = {},
|
||||||
credentialTypes,
|
credentialTypes,
|
||||||
inputSources,
|
inputSources,
|
||||||
@@ -147,6 +151,7 @@ function CredentialForm({
|
|||||||
submitError,
|
submitError,
|
||||||
...rest
|
...rest
|
||||||
}) {
|
}) {
|
||||||
|
const [showExternalTestModal, setShowExternalTestModal] = useState(false);
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: credential.name || '',
|
name: credential.name || '',
|
||||||
description: credential.description || '',
|
description: credential.description || '',
|
||||||
@@ -205,21 +210,61 @@ function CredentialForm({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formik => (
|
{formik => (
|
||||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
<>
|
||||||
<FormColumnLayout>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
<CredentialFormFields
|
<FormColumnLayout>
|
||||||
formik={formik}
|
<CredentialFormFields
|
||||||
initialValues={initialValues}
|
formik={formik}
|
||||||
credentialTypes={credentialTypes}
|
initialValues={initialValues}
|
||||||
{...rest}
|
credentialTypes={credentialTypes}
|
||||||
|
i18n={i18n}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
<FormSubmitError error={submitError} />
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
<ActionGroup>
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Save`)}
|
||||||
|
variant="primary"
|
||||||
|
type="button"
|
||||||
|
onClick={formik.handleSubmit}
|
||||||
|
>
|
||||||
|
{i18n._(t`Save`)}
|
||||||
|
</Button>
|
||||||
|
{formik?.values?.credential_type &&
|
||||||
|
credentialTypes[formik.values.credential_type]?.kind ===
|
||||||
|
'external' && (
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Test`)}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowExternalTestModal(true)}
|
||||||
|
isDisabled={!formik.isValid}
|
||||||
|
>
|
||||||
|
{i18n._(t`Test`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
aria-label={i18n._(t`Cancel`)}
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>
|
||||||
|
</ActionGroup>
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
</FormColumnLayout>
|
||||||
|
</Form>
|
||||||
|
{showExternalTestModal && (
|
||||||
|
<ExternalTestModal
|
||||||
|
credential={credential}
|
||||||
|
credentialType={credentialTypes[formik.values.credential_type]}
|
||||||
|
credentialFormValues={formik.values}
|
||||||
|
onClose={() => setShowExternalTestModal(false)}
|
||||||
/>
|
/>
|
||||||
<FormSubmitError error={submitError} />
|
)}
|
||||||
<FormActionGroup
|
</>
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={formik.handleSubmit}
|
|
||||||
/>
|
|
||||||
</FormColumnLayout>
|
|
||||||
</Form>
|
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ describe('<CredentialForm />', () => {
|
|||||||
test('should display form fields on add properly', async () => {
|
test('should display form fields on add properly', async () => {
|
||||||
addFieldExpects();
|
addFieldExpects();
|
||||||
});
|
});
|
||||||
|
test('should hide Test button initially', () => {
|
||||||
|
expect(wrapper.find('Button[children="Test"]').length).toBe(0);
|
||||||
|
});
|
||||||
test('should update form values', async () => {
|
test('should update form values', async () => {
|
||||||
// name and description change
|
// name and description change
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -221,6 +224,18 @@ describe('<CredentialForm />', () => {
|
|||||||
'There was an error parsing the file. Please check the file formatting and try again.'
|
'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 () => {
|
test('should call handleCancel when Cancel button is clicked', async () => {
|
||||||
expect(onCancel).not.toHaveBeenCalled();
|
expect(onCancel).not.toHaveBeenCalled();
|
||||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function CredentialField({ credentialType, fieldOptions, i18n }) {
|
|||||||
>
|
>
|
||||||
<AnsibleSelect
|
<AnsibleSelect
|
||||||
{...subFormField}
|
{...subFormField}
|
||||||
id="credential_type"
|
id={`credential-${fieldOptions.id}`}
|
||||||
data={selectOptions}
|
data={selectOptions}
|
||||||
onChange={(event, value) => {
|
onChange={(event, value) => {
|
||||||
helpers.setValue(value);
|
helpers.setValue(value);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AlertGroup isToast>
|
||||||
|
{testMessage && testVariant && (
|
||||||
|
<Alert
|
||||||
|
actionClose={
|
||||||
|
<AlertActionCloseButton
|
||||||
|
onClose={() => {
|
||||||
|
setTestMessage(null);
|
||||||
|
setTestVariant(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
<b id="credential-plugin-test-name">{credentialName}</b>
|
||||||
|
<p id="credential-plugin-test-message">{testMessage}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
variant={testVariant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AlertGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialPluginTestAlert.propTypes = {
|
||||||
|
credentialName: string.isRequired,
|
||||||
|
successResponse: shape({}),
|
||||||
|
errorResponse: shape({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
CredentialPluginTestAlert.defaultProps = {
|
||||||
|
successResponse: null,
|
||||||
|
errorResponse: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(CredentialPluginTestAlert);
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
|
export { default as CredentialPluginSelected } from './CredentialPluginSelected';
|
||||||
export { default as CredentialPluginField } from './CredentialPluginField';
|
export { default as CredentialPluginField } from './CredentialPluginField';
|
||||||
|
export { default as CredentialPluginTestAlert } from './CredentialPluginTestAlert';
|
||||||
|
|||||||
198
awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx
Normal file
198
awx/ui_next/src/screens/Credential/shared/ExternalTestModal.jsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Formik
|
||||||
|
initialValues={credentialType.inputs.metadata.reduce(
|
||||||
|
(initialValues, field) => {
|
||||||
|
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 }) => (
|
||||||
|
<Modal
|
||||||
|
title={i18n._(t`Test External Credential`)}
|
||||||
|
isOpen
|
||||||
|
onClose={() => onClose()}
|
||||||
|
variant="small"
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="run-external-credential-test"
|
||||||
|
key="confirm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => handleSubmit()}
|
||||||
|
>
|
||||||
|
{i18n._(t`Run`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="cancel-external-credential-test"
|
||||||
|
key="cancel"
|
||||||
|
variant="link"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Form>
|
||||||
|
<FormFullWidthLayout>
|
||||||
|
{credentialType.inputs.metadata.map(field => {
|
||||||
|
const isRequired = credentialType.inputs?.required.includes(
|
||||||
|
field.id
|
||||||
|
);
|
||||||
|
if (field.type === 'string') {
|
||||||
|
if (field.choices) {
|
||||||
|
return (
|
||||||
|
<FormGroup
|
||||||
|
key={field.id}
|
||||||
|
fieldId={`credential-${field.id}`}
|
||||||
|
label={field.label}
|
||||||
|
labelIcon={
|
||||||
|
field.help_text && (
|
||||||
|
<Tooltip
|
||||||
|
content={field.help_text}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<QuestionCircleIcon />
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
isRequired={isRequired}
|
||||||
|
>
|
||||||
|
<AnsibleSelect
|
||||||
|
name={field.id}
|
||||||
|
value={field.default}
|
||||||
|
id={`credential-${field.id}`}
|
||||||
|
data={field.choices.map(choice => {
|
||||||
|
return {
|
||||||
|
value: choice,
|
||||||
|
key: choice,
|
||||||
|
label: choice,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
onChange={(event, value) => {
|
||||||
|
setFieldValue(field.id, value);
|
||||||
|
}}
|
||||||
|
validate={isRequired ? required(null, i18n) : null}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
id={`credential-${field.id}`}
|
||||||
|
label={field.label}
|
||||||
|
tooltip={field.help_text}
|
||||||
|
name={field.id}
|
||||||
|
type={field.multiline ? 'textarea' : 'text'}
|
||||||
|
isRequired={isRequired}
|
||||||
|
validate={isRequired ? required(null, i18n) : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</FormFullWidthLayout>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
<CredentialPluginTestAlert
|
||||||
|
credentialName={credentialFormValues.name}
|
||||||
|
successResponse={testPluginSuccess}
|
||||||
|
errorResponse={testPluginError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExternalTestModal.proptype = {
|
||||||
|
credential: shape({}),
|
||||||
|
credentialType: shape({}).isRequired,
|
||||||
|
credentialFormValues: shape({}).isRequired,
|
||||||
|
onClose: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
ExternalTestModal.defaultProps = {
|
||||||
|
credential: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(ExternalTestModal);
|
||||||
@@ -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('<ExternalTestModal />', () => {
|
||||||
|
let wrapper;
|
||||||
|
afterEach(() => wrapper.unmount());
|
||||||
|
test('should display metadata fields correctly', async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ExternalTestModal
|
||||||
|
credentialType={credentialType}
|
||||||
|
credentialFormValues={credentialFormValues}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExternalTestModal
|
||||||
|
credential={credential}
|
||||||
|
credentialType={credentialType}
|
||||||
|
credentialFormValues={credentialFormValues}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExternalTestModal
|
||||||
|
credentialType={credentialType}
|
||||||
|
credentialFormValues={credentialFormValues}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExternalTestModal
|
||||||
|
credentialType={credentialType}
|
||||||
|
credentialFormValues={credentialFormValues}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExternalTestModal
|
||||||
|
credentialType={credentialType}
|
||||||
|
credentialFormValues={credentialFormValues}
|
||||||
|
onClose={jest.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default as mockCredentials } from './data.credentials.json';
|
export { default as mockCredentials } from './data.credentials.json';
|
||||||
export { default as mockCredentialType } from './data.credential_type.json';
|
export { default as mockCredentialType } from './data.credential_type.json';
|
||||||
|
export { default as ExternalTestModal } from './ExternalTestModal';
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ function CredentialTypeList({ i18n }) {
|
|||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchCredentialTypes,
|
request: fetchCredentialTypes,
|
||||||
result: { credentialTypes, credentialTypesCount, actions },
|
result: {
|
||||||
|
credentialTypes,
|
||||||
|
credentialTypesCount,
|
||||||
|
actions,
|
||||||
|
relatedSearchableKeys,
|
||||||
|
searchableKeys,
|
||||||
|
},
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
@@ -46,12 +52,20 @@ function CredentialTypeList({ i18n }) {
|
|||||||
credentialTypes: response.data.results,
|
credentialTypes: response.data.results,
|
||||||
credentialTypesCount: response.data.count,
|
credentialTypesCount: response.data.count,
|
||||||
actions: responseActions.data.actions,
|
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]),
|
}, [location]),
|
||||||
{
|
{
|
||||||
credentialTypes: [],
|
credentialTypes: [],
|
||||||
credentialTypesCount: 0,
|
credentialTypesCount: 0,
|
||||||
actions: {},
|
actions: {},
|
||||||
|
relatedSearchableKeys: [],
|
||||||
|
searchableKeys: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +114,8 @@ function CredentialTypeList({ i18n }) {
|
|||||||
pluralizedItemName={i18n._(t`Credential Types`)}
|
pluralizedItemName={i18n._(t`Credential Types`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DatalistToolbar
|
<DatalistToolbar
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import JobList from '../../components/JobList';
|
|||||||
|
|
||||||
import InstanceGroupDetails from './InstanceGroupDetails';
|
import InstanceGroupDetails from './InstanceGroupDetails';
|
||||||
import InstanceGroupEdit from './InstanceGroupEdit';
|
import InstanceGroupEdit from './InstanceGroupEdit';
|
||||||
import Instances from './Instances';
|
import InstanceList from './Instances/InstanceList';
|
||||||
|
|
||||||
function InstanceGroup({ i18n, setBreadcrumb }) {
|
function InstanceGroup({ i18n, setBreadcrumb }) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -123,7 +123,7 @@ function InstanceGroup({ i18n, setBreadcrumb }) {
|
|||||||
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/instances">
|
<Route path="/instance_groups/:id/instances">
|
||||||
<Instances />
|
<InstanceList />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/jobs">
|
<Route path="/instance_groups/:id/jobs">
|
||||||
<JobList
|
<JobList
|
||||||
|
|||||||
@@ -39,13 +39,6 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
|||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
|
||||||
const isAvailable = item => {
|
|
||||||
return (
|
|
||||||
(item.policy_instance_minimum || item.policy_instance_percentage) &&
|
|
||||||
item.capacity
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyIsIsolated = item => {
|
const verifyIsIsolated = item => {
|
||||||
if (item.is_isolated) {
|
if (item.is_isolated) {
|
||||||
return (
|
return (
|
||||||
@@ -89,10 +82,12 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
|||||||
dataCy="instance-group-policy-instance-percentage"
|
dataCy="instance-group-policy-instance-percentage"
|
||||||
content={`${instanceGroup.policy_instance_percentage} %`}
|
content={`${instanceGroup.policy_instance_percentage} %`}
|
||||||
/>
|
/>
|
||||||
{isAvailable(instanceGroup) ? (
|
{instanceGroup.capacity ? (
|
||||||
<DetailBadge
|
<DetailBadge
|
||||||
label={i18n._(t`Used capacity`)}
|
label={i18n._(t`Used capacity`)}
|
||||||
content={`${100 - instanceGroup.percent_capacity_remaining} %`}
|
content={`${Math.round(
|
||||||
|
100 - instanceGroup.percent_capacity_remaining
|
||||||
|
)} %`}
|
||||||
dataCy="instance-group-used-capacity"
|
dataCy="instance-group-used-capacity"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -49,7 +49,13 @@ function InstanceGroupList({ i18n }) {
|
|||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchInstanceGroups,
|
request: fetchInstanceGroups,
|
||||||
result: { instanceGroups, instanceGroupsCount, actions },
|
result: {
|
||||||
|
instanceGroups,
|
||||||
|
instanceGroupsCount,
|
||||||
|
actions,
|
||||||
|
relatedSearchableKeys,
|
||||||
|
searchableKeys,
|
||||||
|
},
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
@@ -63,12 +69,20 @@ function InstanceGroupList({ i18n }) {
|
|||||||
instanceGroups: response.data.results,
|
instanceGroups: response.data.results,
|
||||||
instanceGroupsCount: response.data.count,
|
instanceGroupsCount: response.data.count,
|
||||||
actions: responseActions.data.actions,
|
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]),
|
}, [location]),
|
||||||
{
|
{
|
||||||
instanceGroups: [],
|
instanceGroups: [],
|
||||||
instanceGroupsCount: 0,
|
instanceGroupsCount: 0,
|
||||||
actions: {},
|
actions: {},
|
||||||
|
relatedSearchableKeys: [],
|
||||||
|
searchableKeys: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,6 +185,8 @@ function InstanceGroupList({ i18n }) {
|
|||||||
pluralizedItemName={pluralizedItemName}
|
pluralizedItemName={pluralizedItemName}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DatalistToolbar
|
<DatalistToolbar
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const instanceGroups = {
|
|||||||
|
|
||||||
const options = { data: { actions: { POST: true } } };
|
const options = { data: { actions: { POST: true } } };
|
||||||
|
|
||||||
describe('<InstanceGroupList', () => {
|
describe('<InstanceGroupList />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
test('should have data fetched and render 3 rows', async () => {
|
test('should have data fetched and render 3 rows', async () => {
|
||||||
|
|||||||
@@ -60,23 +60,16 @@ function InstanceGroupListItem({
|
|||||||
}) {
|
}) {
|
||||||
const labelId = `check-action-${instanceGroup.id}`;
|
const labelId = `check-action-${instanceGroup.id}`;
|
||||||
|
|
||||||
const isAvailable = item => {
|
|
||||||
return (
|
|
||||||
(item.policy_instance_minimum || item.policy_instance_percentage) &&
|
|
||||||
item.capacity
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isContainerGroup = item => {
|
const isContainerGroup = item => {
|
||||||
return item.is_containerized;
|
return item.is_containerized;
|
||||||
};
|
};
|
||||||
|
|
||||||
function usedCapacity(item) {
|
function usedCapacity(item) {
|
||||||
if (!isContainerGroup(item)) {
|
if (!isContainerGroup(item)) {
|
||||||
if (isAvailable(item)) {
|
if (item.capacity) {
|
||||||
return (
|
return (
|
||||||
<Progress
|
<Progress
|
||||||
value={100 - item.percent_capacity_remaining}
|
value={Math.round(100 - item.percent_capacity_remaining)}
|
||||||
measureLocation={ProgressMeasureLocation.top}
|
measureLocation={ProgressMeasureLocation.top}
|
||||||
size={ProgressSize.sm}
|
size={ProgressSize.sm}
|
||||||
title={i18n._(t`Used capacity`)}
|
title={i18n._(t`Used capacity`)}
|
||||||
|
|||||||
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal file
245
awx/ui_next/src/screens/InstanceGroup/Instances/InstanceList.jsx
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
|
import 'styled-components/macro';
|
||||||
|
|
||||||
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
|
import PaginatedDataList, {
|
||||||
|
ToolbarAddButton,
|
||||||
|
} from '../../../components/PaginatedDataList';
|
||||||
|
import DisassociateButton from '../../../components/DisassociateButton';
|
||||||
|
import AssociateModal from '../../../components/AssociateModal';
|
||||||
|
import AlertModal from '../../../components/AlertModal';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
|
|
||||||
|
import useRequest, {
|
||||||
|
useDeleteItems,
|
||||||
|
useDismissableError,
|
||||||
|
} from '../../../util/useRequest';
|
||||||
|
import useSelected from '../../../util/useSelected';
|
||||||
|
import { InstanceGroupsAPI, InstancesAPI } from '../../../api';
|
||||||
|
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
|
||||||
|
|
||||||
|
import InstanceListItem from './InstanceListItem';
|
||||||
|
|
||||||
|
const QS_CONFIG = getQSConfig('instance', {
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
order_by: 'hostname',
|
||||||
|
});
|
||||||
|
|
||||||
|
function InstanceList({ i18n }) {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const { id: instanceGroupId } = useParams();
|
||||||
|
|
||||||
|
const {
|
||||||
|
result: {
|
||||||
|
instances,
|
||||||
|
count,
|
||||||
|
actions,
|
||||||
|
relatedSearchableKeys,
|
||||||
|
searchableKeys,
|
||||||
|
},
|
||||||
|
error: contentError,
|
||||||
|
isLoading,
|
||||||
|
request: fetchInstances,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PaginatedDataList
|
||||||
|
contentError={contentError}
|
||||||
|
hasContentLoading={isLoading || isDisassociateLoading}
|
||||||
|
items={instances}
|
||||||
|
itemCount={count}
|
||||||
|
pluralizedItemName={i18n._(t`Instances`)}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
onRowClick={handleSelect}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
toolbarSearchColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'hostname',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
toolbarSortColumns={[
|
||||||
|
{
|
||||||
|
name: i18n._(t`Name`),
|
||||||
|
key: 'hostname',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
renderToolbar={props => (
|
||||||
|
<DataListToolbar
|
||||||
|
{...props}
|
||||||
|
showSelectAll
|
||||||
|
isAllSelected={isAllSelected}
|
||||||
|
onSelectAll={isSelected =>
|
||||||
|
setSelected(isSelected ? [...instances] : [])
|
||||||
|
}
|
||||||
|
qsConfig={QS_CONFIG}
|
||||||
|
additionalControls={[
|
||||||
|
...(canAdd
|
||||||
|
? [
|
||||||
|
<ToolbarAddButton
|
||||||
|
key="associate"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
defaultLabel={i18n._(t`Associate`)}
|
||||||
|
/>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
<DisassociateButton
|
||||||
|
verifyCannotDisassociate={false}
|
||||||
|
key="disassociate"
|
||||||
|
onDisassociate={handleDisassociate}
|
||||||
|
itemsToDisassociate={selected}
|
||||||
|
modalTitle={i18n._(
|
||||||
|
t`Disassociate instance from instance group?`
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
emptyStateControls={
|
||||||
|
canAdd ? (
|
||||||
|
<ToolbarAddButton
|
||||||
|
key="add"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItem={instance => (
|
||||||
|
<InstanceListItem
|
||||||
|
key={instance.id}
|
||||||
|
value={instance.hostname}
|
||||||
|
instance={instance}
|
||||||
|
onSelect={() => handleSelect(instance)}
|
||||||
|
isSelected={selected.some(row => row.id === instance.id)}
|
||||||
|
fetchInstances={fetchInstances}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<AssociateModal
|
||||||
|
header={i18n._(t`Instances`)}
|
||||||
|
fetchRequest={fetchInstancesToAssociate}
|
||||||
|
isModalOpen={isModalOpen}
|
||||||
|
onAssociate={handleAssociate}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title={i18n._(t`Select Instances`)}
|
||||||
|
optionsRequest={readInstancesOptions}
|
||||||
|
displayKey="hostname"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={error}
|
||||||
|
onClose={dismissError}
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
variant="error"
|
||||||
|
>
|
||||||
|
{associateError
|
||||||
|
? i18n._(t`Failed to associate.`)
|
||||||
|
: i18n._(t`Failed to disassociate one or more instances.`)}
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(InstanceList);
|
||||||
@@ -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('<InstanceList/>', () => {
|
||||||
|
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(
|
||||||
|
<Route path="/instance_groups/:id/instances">
|
||||||
|
<InstanceList />
|
||||||
|
</Route>,
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Progress
|
||||||
|
value={Math.round(100 - item.percent_capacity_remaining)}
|
||||||
|
measureLocation={ProgressMeasureLocation.top}
|
||||||
|
size={ProgressSize.sm}
|
||||||
|
title={i18n._(t`Used capacity`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Unavailable>{i18n._(t`Unavailable`)}</Unavailable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataListItem
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
id={`${instance.id}`}
|
||||||
|
key={instance.id}
|
||||||
|
>
|
||||||
|
<DataListItemRow>
|
||||||
|
<DataListCheck
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
checked={isSelected}
|
||||||
|
id={`instances-${instance.id}`}
|
||||||
|
onChange={onSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataListItemCells
|
||||||
|
dataListCells={[
|
||||||
|
<DataListCell key="name" aria-label={i18n._(t`instance host name`)}>
|
||||||
|
<b>{instance.hostname}</b>
|
||||||
|
</DataListCell>,
|
||||||
|
<DataListCell key="type" aria-label={i18n._(t`instance type`)}>
|
||||||
|
<b css="margin-right: 24px">{i18n._(t`Type`)}</b>
|
||||||
|
<span id={labelId}>
|
||||||
|
{instance.managed_by_policy
|
||||||
|
? i18n._(t`Auto`)
|
||||||
|
: i18n._(t`Manual`)}
|
||||||
|
</span>
|
||||||
|
</DataListCell>,
|
||||||
|
<DataListCell
|
||||||
|
key="related-field-counts"
|
||||||
|
aria-label={i18n._(t`instance counts`)}
|
||||||
|
width={2}
|
||||||
|
>
|
||||||
|
<ListGroup>
|
||||||
|
<b>{i18n._(t`Running jobs`)}</b>
|
||||||
|
<Badge isRead>{instance.jobs_running}</Badge>
|
||||||
|
</ListGroup>
|
||||||
|
<ListGroup>
|
||||||
|
<b>{i18n._(t`Total jobs`)}</b>
|
||||||
|
<Badge isRead>{instance.jobs_total}</Badge>
|
||||||
|
</ListGroup>
|
||||||
|
</DataListCell>,
|
||||||
|
<DataListCell
|
||||||
|
key="capacity"
|
||||||
|
aria-label={i18n._(t`instance group used capacity`)}
|
||||||
|
>
|
||||||
|
{usedCapacity(instance)}
|
||||||
|
</DataListCell>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<DataListAction
|
||||||
|
aria-label="actions"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
id={labelId}
|
||||||
|
>
|
||||||
|
<InstanceToggle
|
||||||
|
css="display: inline-flex;"
|
||||||
|
fetchInstances={fetchInstances}
|
||||||
|
instance={instance}
|
||||||
|
/>
|
||||||
|
</DataListAction>
|
||||||
|
</DataListItemRow>
|
||||||
|
</DataListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
InstanceListItem.prototype = {
|
||||||
|
instance: Instance.isRequired,
|
||||||
|
isSelected: bool.isRequired,
|
||||||
|
onSelect: func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withI18n()(InstanceListItem);
|
||||||
@@ -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('<InstanceListItem/>', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
test('should mount successfully', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InstanceListItem
|
||||||
|
instance={instance[0]}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('InstanceListItem').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render the proper data instance', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InstanceListItem
|
||||||
|
instance={instance[0]}
|
||||||
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<InstanceListItem
|
||||||
|
instance={instance[0]}
|
||||||
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(wrapper.find('input#instances-1').prop('checked')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display instance toggle', () => {
|
||||||
|
expect(wrapper.find('InstanceToggle').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
|
||||||
|
|
||||||
function Instances() {
|
|
||||||
return (
|
|
||||||
<PageSection>
|
|
||||||
<Card>
|
|
||||||
<div>Instances</div>
|
|
||||||
</Card>
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Instances;
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default } from './Instances';
|
export { default as InstanceList } from './InstanceList';
|
||||||
|
export { default as InstanceListItem } from './InstanceListItem';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import {
|
import {
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
PencilAltIcon,
|
PencilAltIcon,
|
||||||
|
ProjectDiagramIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
} from '@patternfly/react-icons';
|
} from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
@@ -32,7 +33,7 @@ const DataListAction = styled(_DataListAction)`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 16px;
|
grid-gap: 16px;
|
||||||
grid-template-columns: repeat(3, 40px);
|
grid-template-columns: repeat(4, 40px);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function TemplateListItem({
|
function TemplateListItem({
|
||||||
@@ -104,6 +105,20 @@ function TemplateListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
||||||
|
{template.type === 'workflow_job_template' && (
|
||||||
|
<Tooltip content={i18n._(t`Visualizer`)} position="top">
|
||||||
|
<Button
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Visualizer`)}
|
||||||
|
css="grid-column: 1"
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||||
|
>
|
||||||
|
<ProjectDiagramIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{template.summary_fields.user_capabilities.start && (
|
{template.summary_fields.user_capabilities.start && (
|
||||||
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
||||||
<LaunchButton resource={template}>
|
<LaunchButton resource={template}>
|
||||||
@@ -111,7 +126,7 @@ function TemplateListItem({
|
|||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
aria-label={i18n._(t`Launch template`)}
|
aria-label={i18n._(t`Launch template`)}
|
||||||
css="grid-column: 1"
|
css="grid-column: 2"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={handleLaunch}
|
onClick={handleLaunch}
|
||||||
>
|
>
|
||||||
@@ -126,7 +141,7 @@ function TemplateListItem({
|
|||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
aria-label={i18n._(t`Edit Template`)}
|
aria-label={i18n._(t`Edit Template`)}
|
||||||
css="grid-column: 2"
|
css="grid-column: 3"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/templates/${template.type}/${template.id}/edit`}
|
to={`/templates/${template.type}/${template.id}/edit`}
|
||||||
|
|||||||
@@ -239,4 +239,29 @@ describe('<TemplateListItem />', () => {
|
|||||||
);
|
);
|
||||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should render visualizer button for workflow', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
...mockJobTemplateData,
|
||||||
|
type: 'workflow_job_template',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not render visualizer button for job template', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function WorkflowJobTemplateDetail({ template, i18n }) {
|
|||||||
const canLaunch = summary_fields?.user_capabilities?.start;
|
const canLaunch = summary_fields?.user_capabilities?.start;
|
||||||
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
|
const recentPlaybookJobs = summary_fields.recent_jobs.map(job => ({
|
||||||
...job,
|
...job,
|
||||||
type: 'job',
|
type: 'workflow_job',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ function JobTemplateForm({
|
|||||||
const [jobTagsField, , jobTagsHelpers] = useField('job_tags');
|
const [jobTagsField, , jobTagsHelpers] = useField('job_tags');
|
||||||
const [skipTagsField, , skipTagsHelpers] = useField('skip_tags');
|
const [skipTagsField, , skipTagsHelpers] = useField('skip_tags');
|
||||||
|
|
||||||
|
const [, webhookServiceMeta, webhookServiceHelpers] = useField(
|
||||||
|
'webhook_service'
|
||||||
|
);
|
||||||
|
const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url');
|
||||||
|
const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key');
|
||||||
|
const [, webhookCredentialMeta, webhookCredentialHelpers] = useField(
|
||||||
|
'webhook_credential'
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
request: fetchProject,
|
request: fetchProject,
|
||||||
error: projectContentError,
|
error: projectContentError,
|
||||||
@@ -126,6 +135,21 @@ function JobTemplateForm({
|
|||||||
loadRelatedInstanceGroups();
|
loadRelatedInstanceGroups();
|
||||||
}, [loadRelatedInstanceGroups]);
|
}, [loadRelatedInstanceGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableWebhooks) {
|
||||||
|
webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
|
||||||
|
webhookUrlHelpers.setValue(webhookUrlMeta.initialValue);
|
||||||
|
webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
|
||||||
|
webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue);
|
||||||
|
} else {
|
||||||
|
webhookServiceHelpers.setValue('');
|
||||||
|
webhookUrlHelpers.setValue('');
|
||||||
|
webhookKeyHelpers.setValue('');
|
||||||
|
webhookCredentialHelpers.setValue(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enableWebhooks]);
|
||||||
|
|
||||||
const handleProjectValidation = project => {
|
const handleProjectValidation = project => {
|
||||||
if (!project && projectMeta.touched) {
|
if (!project && projectMeta.touched) {
|
||||||
return i18n._(t`Select a value for this field`);
|
return i18n._(t`Select a value for this field`);
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ import {
|
|||||||
|
|
||||||
function WebhookSubForm({ i18n, templateType }) {
|
function WebhookSubForm({ i18n, templateType }) {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const { origin } = document.location;
|
const { origin } = document.location;
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -35,11 +33,7 @@ function WebhookSubForm({ i18n, templateType }) {
|
|||||||
webhookServiceMeta,
|
webhookServiceMeta,
|
||||||
webhookServiceHelpers,
|
webhookServiceHelpers,
|
||||||
] = useField('webhook_service');
|
] = useField('webhook_service');
|
||||||
|
const [webhookUrlField, , webhookUrlHelpers] = useField('webhook_url');
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const [webhookUrlField, webhookUrlMeta, webhookUrlHelpers] = useField(
|
|
||||||
'webhook_url'
|
|
||||||
);
|
|
||||||
const [webhookKeyField, webhookKeyMeta, webhookKeyHelpers] = useField(
|
const [webhookKeyField, webhookKeyMeta, webhookKeyHelpers] = useField(
|
||||||
'webhook_key'
|
'webhook_key'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import PropTypes, { shape } from 'prop-types';
|
import PropTypes, { shape } from 'prop-types';
|
||||||
|
|
||||||
@@ -57,6 +57,29 @@ function WorkflowJobTemplateForm({
|
|||||||
'organization'
|
'organization'
|
||||||
);
|
);
|
||||||
const [scmField, , scmHelpers] = useField('scm_branch');
|
const [scmField, , scmHelpers] = useField('scm_branch');
|
||||||
|
const [, webhookServiceMeta, webhookServiceHelpers] = useField(
|
||||||
|
'webhook_service'
|
||||||
|
);
|
||||||
|
const [, webhookUrlMeta, webhookUrlHelpers] = useField('webhook_url');
|
||||||
|
const [, webhookKeyMeta, webhookKeyHelpers] = useField('webhook_key');
|
||||||
|
const [, webhookCredentialMeta, webhookCredentialHelpers] = useField(
|
||||||
|
'webhook_credential'
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enableWebhooks) {
|
||||||
|
webhookServiceHelpers.setValue(webhookServiceMeta.initialValue);
|
||||||
|
webhookUrlHelpers.setValue(webhookUrlMeta.initialValue);
|
||||||
|
webhookKeyHelpers.setValue(webhookKeyMeta.initialValue);
|
||||||
|
webhookCredentialHelpers.setValue(webhookCredentialMeta.initialValue);
|
||||||
|
} else {
|
||||||
|
webhookServiceHelpers.setValue('');
|
||||||
|
webhookUrlHelpers.setValue('');
|
||||||
|
webhookKeyHelpers.setValue('');
|
||||||
|
webhookCredentialHelpers.setValue(null);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [enableWebhooks]);
|
||||||
|
|
||||||
if (hasContentError) {
|
if (hasContentError) {
|
||||||
return <ContentError error={hasContentError} />;
|
return <ContentError error={hasContentError} />;
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ export const InstanceGroup = shape({
|
|||||||
name: string.isRequired,
|
name: string.isRequired,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Instance = shape({
|
||||||
|
id: number.isRequired,
|
||||||
|
name: string.isRequired,
|
||||||
|
});
|
||||||
|
|
||||||
export const Label = shape({
|
export const Label = shape({
|
||||||
id: number.isRequired,
|
id: number.isRequired,
|
||||||
name: string.isRequired,
|
name: string.isRequired,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export default function useRequest(makeRequest, initialValue) {
|
|||||||
request: useCallback(
|
request: useCallback(
|
||||||
async (...args) => {
|
async (...args) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
if (isMounted.current) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await makeRequest(...args);
|
const response = await makeRequest(...args);
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const defaultContexts = {
|
|||||||
ansible_version: null,
|
ansible_version: null,
|
||||||
custom_virtualenvs: [],
|
custom_virtualenvs: [],
|
||||||
version: null,
|
version: null,
|
||||||
|
me: { is_superuser: true },
|
||||||
toJSON: () => '/config/',
|
toJSON: () => '/config/',
|
||||||
},
|
},
|
||||||
router: {
|
router: {
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ The following notes are changes that may require changes to playbooks:
|
|||||||
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
|
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
|
||||||
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
|
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
|
||||||
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
|
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
|
||||||
- The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
|
- The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
|
||||||
- `tower_credential` no longer supports passing a file name to ssh_key_data.
|
- `tower_credential` no longer supports passing a file name to ssh_key_data.
|
||||||
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module.
|
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module.
|
||||||
|
|
||||||
## Running Unit Tests
|
## Running Unit Tests
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ plugin_routing:
|
|||||||
deprecation:
|
deprecation:
|
||||||
removal_date: TBD
|
removal_date: TBD
|
||||||
warning_text: see plugin documentation for details
|
warning_text: see plugin documentation for details
|
||||||
tower_notifitcation:
|
tower_notification:
|
||||||
redirect: tower_notification_template
|
redirect: tower_notification_template
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ class TowerAPIModule(TowerModule):
|
|||||||
return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations)
|
return self.create_if_needed(existing_item, new_item, endpoint, on_create=on_create, item_type=item_type, associations=associations)
|
||||||
|
|
||||||
def logout(self):
|
def logout(self):
|
||||||
if self.authenticated:
|
if self.authenticated and self.oauth_token_id:
|
||||||
# Attempt to delete our current token from /api/v2/tokens/
|
# Attempt to delete our current token from /api/v2/tokens/
|
||||||
# Post to the tokens endpoint with baisc auth to try and get a token
|
# Post to the tokens endpoint with baisc auth to try and get a token
|
||||||
api_token_url = (
|
api_token_url = (
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ options:
|
|||||||
type: str
|
type: str
|
||||||
host_filter:
|
host_filter:
|
||||||
description:
|
description:
|
||||||
- The host_filter field. Only useful when C(kind=smart).
|
- The host_filter field. Only useful when C(kind=smart).
|
||||||
|
type: str
|
||||||
|
insights_credential:
|
||||||
|
description:
|
||||||
|
- Credentials to be used by hosts belonging to this inventory when accessing Red Hat Insights API.
|
||||||
type: str
|
type: str
|
||||||
state:
|
state:
|
||||||
description:
|
description:
|
||||||
@@ -84,6 +88,7 @@ def main():
|
|||||||
variables=dict(type='dict'),
|
variables=dict(type='dict'),
|
||||||
kind=dict(choices=['', 'smart'], default=''),
|
kind=dict(choices=['', 'smart'], default=''),
|
||||||
host_filter=dict(),
|
host_filter=dict(),
|
||||||
|
insights_credential=dict(),
|
||||||
state=dict(choices=['present', 'absent'], default='present'),
|
state=dict(choices=['present', 'absent'], default='present'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +103,7 @@ def main():
|
|||||||
state = module.params.get('state')
|
state = module.params.get('state')
|
||||||
kind = module.params.get('kind')
|
kind = module.params.get('kind')
|
||||||
host_filter = module.params.get('host_filter')
|
host_filter = module.params.get('host_filter')
|
||||||
|
insights_credential = module.params.get('insights_credential')
|
||||||
|
|
||||||
# Attempt to look up the related items the user specified (these will fail the module if not found)
|
# Attempt to look up the related items the user specified (these will fail the module if not found)
|
||||||
org_id = module.resolve_name_to_id('organizations', organization)
|
org_id = module.resolve_name_to_id('organizations', organization)
|
||||||
@@ -125,6 +131,8 @@ def main():
|
|||||||
inventory_fields['description'] = description
|
inventory_fields['description'] = description
|
||||||
if variables is not None:
|
if variables is not None:
|
||||||
inventory_fields['variables'] = json.dumps(variables)
|
inventory_fields['variables'] = json.dumps(variables)
|
||||||
|
if insights_credential is not None:
|
||||||
|
inventory_fields['insights_credential'] = module.resolve_name_to_id('credentials', insights_credential)
|
||||||
|
|
||||||
# We need to perform a check to make sure you are not trying to convert a regular inventory into a smart one.
|
# We need to perform a check to make sure you are not trying to convert a regular inventory into a smart one.
|
||||||
if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart':
|
if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart':
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import pytest
|
|||||||
from awx.main.tests.functional.conftest import _request
|
from awx.main.tests.functional.conftest import _request
|
||||||
from awx.main.models import Organization, Project, Inventory, JobTemplate, Credential, CredentialType
|
from awx.main.models import Organization, Project, Inventory, JobTemplate, Credential, CredentialType
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import tower_cli # noqa
|
import tower_cli # noqa
|
||||||
HAS_TOWER_CLI = True
|
HAS_TOWER_CLI = True
|
||||||
@@ -107,8 +109,9 @@ def run_module(request, collection_import):
|
|||||||
kwargs_copy['data'][k] = v
|
kwargs_copy['data'][k] = v
|
||||||
|
|
||||||
# make request
|
# make request
|
||||||
rf = _request(method.lower())
|
with transaction.atomic():
|
||||||
django_response = rf(url, user=request_user, expect=None, **kwargs_copy)
|
rf = _request(method.lower())
|
||||||
|
django_response = rf(url, user=request_user, expect=None, **kwargs_copy)
|
||||||
|
|
||||||
# requests library response object is different from the Django response, but they are the same concept
|
# requests library response object is different from the Django response, but they are the same concept
|
||||||
# this converts the Django response object into a requests response object for consumption
|
# this converts the Django response object into a requests response object for consumption
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ needs_development = [
|
|||||||
]
|
]
|
||||||
needs_param_development = {
|
needs_param_development = {
|
||||||
'tower_host': ['instance_id'],
|
'tower_host': ['instance_id'],
|
||||||
'tower_inventory': ['insights_credential'],
|
|
||||||
}
|
}
|
||||||
# -----------------------------------------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ __metaclass__ = type
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from awx.main.models import Inventory
|
from awx.main.models import Inventory, Credential
|
||||||
|
from awx.main.tests.functional.conftest import insights_credential, credentialtype_insights
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_inventory_create(run_module, admin_user, organization):
|
def test_inventory_create(run_module, admin_user, organization, insights_credential):
|
||||||
|
# Create an insights credential
|
||||||
|
|
||||||
result = run_module('tower_inventory', {
|
result = run_module('tower_inventory', {
|
||||||
'name': 'foo-inventory',
|
'name': 'foo-inventory',
|
||||||
'organization': organization.name,
|
'organization': organization.name,
|
||||||
'variables': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}},
|
'variables': {'foo': 'bar', 'another-foo': {'barz': 'bar2'}},
|
||||||
|
'insights_credential': insights_credential.name,
|
||||||
'state': 'present'
|
'state': 'present'
|
||||||
}, admin_user)
|
}, admin_user)
|
||||||
|
assert not result.get('failed', False), result.get('msg', result)
|
||||||
|
|
||||||
inv = Inventory.objects.get(name='foo-inventory')
|
inv = Inventory.objects.get(name='foo-inventory')
|
||||||
assert inv.variables == '{"foo": "bar", "another-foo": {"barz": "bar2"}}'
|
assert inv.variables == '{"foo": "bar", "another-foo": {"barz": "bar2"}}'
|
||||||
|
assert inv.insights_credential.name == insights_credential.name
|
||||||
|
|
||||||
result.pop('module_args', None)
|
result.pop('module_args', None)
|
||||||
result.pop('invocation', None)
|
result.pop('invocation', None)
|
||||||
|
|||||||
@@ -1,101 +1,140 @@
|
|||||||
---
|
---
|
||||||
|
- name: Generate a test ID
|
||||||
|
set_fact:
|
||||||
|
test_id: "{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||||
|
|
||||||
- name: Generate names
|
- name: Generate names
|
||||||
set_fact:
|
set_fact:
|
||||||
inv_name1: "AWX-Collection-tests-tower_inventory-inv1-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
inv_name1: "AWX-Collection-tests-tower_inventory-inv1-{{ test_id }}"
|
||||||
inv_name2: "AWX-Collection-tests-tower_inventory-inv2-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
inv_name2: "AWX-Collection-tests-tower_inventory-inv2-{{ test_id }}"
|
||||||
|
cred_name1: "AWX-Collection-tests-tower_inventory-cred1-{{ test_id }}"
|
||||||
|
|
||||||
- name: Create an Inventory
|
- block:
|
||||||
tower_inventory:
|
- name: Create an Insights Credential
|
||||||
name: "{{ inv_name1 }}"
|
tower_credential:
|
||||||
organization: Default
|
name: "{{ cred_name1 }}"
|
||||||
state: present
|
organization: Default
|
||||||
register: result
|
kind: insights
|
||||||
|
inputs:
|
||||||
|
username: joe
|
||||||
|
password: secret
|
||||||
|
state: present
|
||||||
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Test Inventory module idempotency
|
- name: Create an Inventory
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name1 }}"
|
name: "{{ inv_name1 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: present
|
insights_credential: "{{ cred_name1 }}"
|
||||||
register: result
|
state: present
|
||||||
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is not changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Fail Change Regular to Smart
|
- name: Test Inventory module idempotency
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name1 }}"
|
name: "{{ inv_name1 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
kind: smart
|
insights_credential: "{{ cred_name1 }}"
|
||||||
register: result
|
state: present
|
||||||
ignore_errors: true
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is failed"
|
- "result is not changed"
|
||||||
|
|
||||||
- name: Create a smart inventory
|
- name: Fail Change Regular to Smart
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name2 }}"
|
name: "{{ inv_name1 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
kind: smart
|
kind: smart
|
||||||
host_filter: name=foo
|
register: result
|
||||||
register: result
|
ignore_errors: true
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is failed"
|
||||||
|
|
||||||
- name: Delete a smart inventory
|
- name: Create a smart inventory
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name2 }}"
|
name: "{{ inv_name2 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
kind: smart
|
kind: smart
|
||||||
host_filter: name=foo
|
host_filter: name=foo
|
||||||
state: absent
|
register: result
|
||||||
register: result
|
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Delete an Inventory
|
- name: Delete a smart inventory
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name1 }}"
|
name: "{{ inv_name2 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
kind: smart
|
||||||
register: result
|
host_filter: name=foo
|
||||||
|
state: absent
|
||||||
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Delete a Non-Existent Inventory
|
- name: Delete an Inventory
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: "{{ inv_name1 }}"
|
name: "{{ inv_name1 }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
state: absent
|
||||||
register: result
|
register: result
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is not changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Check module fails with correct msg
|
- name: Delete a Non-Existent Inventory
|
||||||
tower_inventory:
|
tower_inventory:
|
||||||
name: test-inventory
|
name: "{{ inv_name1 }}"
|
||||||
description: Inventory Description
|
organization: Default
|
||||||
organization: test-non-existing-org
|
state: absent
|
||||||
state: present
|
register: result
|
||||||
register: result
|
|
||||||
ignore_errors: true
|
|
||||||
|
|
||||||
- assert:
|
- assert:
|
||||||
that:
|
that:
|
||||||
- "result is not changed"
|
- "result is not changed"
|
||||||
- "result.msg =='Failed to update inventory, organization not found: The requested object could not be found.'
|
|
||||||
or result.msg =='The organizations test-non-existing-org was not found on the Tower server'"
|
- name: Check module fails with correct msg
|
||||||
|
tower_inventory:
|
||||||
|
name: test-inventory
|
||||||
|
description: Inventory Description
|
||||||
|
organization: test-non-existing-org
|
||||||
|
state: present
|
||||||
|
register: result
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- assert:
|
||||||
|
that:
|
||||||
|
- "result is not changed"
|
||||||
|
- "result.msg =='Failed to update inventory, organization not found: The requested object could not be found.'
|
||||||
|
or result.msg =='The organizations test-non-existing-org was not found on the Tower server'"
|
||||||
|
always:
|
||||||
|
- name: Delete Inventories
|
||||||
|
tower_inventory:
|
||||||
|
name: "{{ item }}"
|
||||||
|
organization: Default
|
||||||
|
state: absent
|
||||||
|
loop:
|
||||||
|
- "{{ inv_name1 }}"
|
||||||
|
- "{{ inv_name2 }}"
|
||||||
|
|
||||||
|
- name: Delete Insights Credential
|
||||||
|
tower_credential:
|
||||||
|
name: "{{ cred_name1 }}"
|
||||||
|
organization: "Default"
|
||||||
|
kind: insights
|
||||||
|
state: absent
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
jt2: "AWX-Collection-tests-tower_job_template-jt2-{{ test_id }}"
|
jt2: "AWX-Collection-tests-tower_job_template-jt2-{{ test_id }}"
|
||||||
lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}"
|
lab1: "AWX-Collection-tests-tower_job_template-lab1-{{ test_id }}"
|
||||||
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
|
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
|
||||||
webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}"
|
webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}"
|
||||||
|
|
||||||
- name: Create a Demo Project
|
- name: Create a Demo Project
|
||||||
tower_project:
|
tower_project:
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
organization: Default
|
organization: Default
|
||||||
|
|
||||||
- name: Add email notification
|
- name: Add email notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ email_not }}"
|
name: "{{ email_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
notification_type: email
|
notification_type: email
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
state: present
|
state: present
|
||||||
|
|
||||||
- name: Add webhook notification
|
- name: Add webhook notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ webhook_not }}"
|
name: "{{ webhook_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
notification_type: webhook
|
notification_type: webhook
|
||||||
@@ -366,13 +366,13 @@
|
|||||||
# You can't delete a label directly so no cleanup needed
|
# You can't delete a label directly so no cleanup needed
|
||||||
|
|
||||||
- name: Delete email notification
|
- name: Delete email notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ email_not }}"
|
name: "{{ email_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
state: absent
|
||||||
|
|
||||||
- name: Delete webhook notification
|
- name: Delete webhook notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ webhook_not }}"
|
name: "{{ webhook_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
state: absent
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
irc_not: "AWX-Collection-tests-tower_notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
irc_not: "AWX-Collection-tests-tower_notification_template-irc-not-{{ lookup('password', '/dev/null chars=ascii_letters length=16') }}"
|
||||||
|
|
||||||
- name: Test deprecation warnings with legacy name
|
- name: Test deprecation warnings with legacy name
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ slack_not }}"
|
name: "{{ slack_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
notification_type: slack
|
notification_type: slack
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}"
|
jt2_name: "AWX-Collection-tests-tower_workflow_job_template-jt2-{{ test_id }}"
|
||||||
wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}"
|
wfjt_name: "AWX-Collection-tests-tower_workflow_job_template-wfjt-{{ test_id }}"
|
||||||
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
|
email_not: "AWX-Collection-tests-tower_job_template-email-not-{{ test_id }}"
|
||||||
webhook_not: "AWX-Collection-tests-tower_notification-wehbook-not-{{ test_id }}"
|
webhook_not: "AWX-Collection-tests-tower_notification_template-wehbook-not-{{ test_id }}"
|
||||||
|
|
||||||
- name: Create an SCM Credential
|
- name: Create an SCM Credential
|
||||||
tower_credential:
|
tower_credential:
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Add email notification
|
- name: Add email notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ email_not }}"
|
name: "{{ email_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
notification_type: email
|
notification_type: email
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
state: present
|
state: present
|
||||||
|
|
||||||
- name: Add webhook notification
|
- name: Add webhook notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ webhook_not }}"
|
name: "{{ webhook_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
notification_type: webhook
|
notification_type: webhook
|
||||||
@@ -264,13 +264,13 @@
|
|||||||
- "result is changed"
|
- "result is changed"
|
||||||
|
|
||||||
- name: Delete email notification
|
- name: Delete email notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ email_not }}"
|
name: "{{ email_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
state: absent
|
||||||
|
|
||||||
- name: Delete webhook notification
|
- name: Delete webhook notification
|
||||||
tower_notification:
|
tower_notification_template:
|
||||||
name: "{{ webhook_not }}"
|
name: "{{ webhook_not }}"
|
||||||
organization: Default
|
organization: Default
|
||||||
state: absent
|
state: absent
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ plugins/modules/tower_send.py validate-modules:deprecation-mismatch
|
|||||||
plugins/modules/tower_workflow_template.py validate-modules:deprecation-mismatch
|
plugins/modules/tower_workflow_template.py validate-modules:deprecation-mismatch
|
||||||
plugins/modules/tower_credential.py pylint:wrong-collection-deprecated-version-tag
|
plugins/modules/tower_credential.py pylint:wrong-collection-deprecated-version-tag
|
||||||
plugins/modules/tower_job_wait.py pylint:wrong-collection-deprecated-version-tag
|
plugins/modules/tower_job_wait.py pylint:wrong-collection-deprecated-version-tag
|
||||||
plugins/modules/tower_notification.py pylint:wrong-collection-deprecated-version-tag
|
plugins/modules/tower_notification_template.py pylint:wrong-collection-deprecated-version-tag
|
||||||
plugins/inventory/tower.py pylint:raise-missing-from
|
plugins/inventory/tower.py pylint:raise-missing-from
|
||||||
plugins/inventory/tower.py pylint:super-with-arguments
|
plugins/inventory/tower.py pylint:super-with-arguments
|
||||||
plugins/lookup/tower_schedule_rrule.py pylint:raise-missing-from
|
plugins/lookup/tower_schedule_rrule.py pylint:raise-missing-from
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ Notable releases of the `{{ collection_namespace }}.{{ collection_package }}` co
|
|||||||
|
|
||||||
The following notes are changes that may require changes to playbooks:
|
The following notes are changes that may require changes to playbooks:
|
||||||
|
|
||||||
|
- The module tower_notification was renamed tower_notification_template. In ansible >= 2.10 there is a seemless redirect. Ansible 2.9 does not respect the redirect.
|
||||||
- When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired.
|
- When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired.
|
||||||
- Creating a "scan" type job template is no longer supported.
|
- Creating a "scan" type job template is no longer supported.
|
||||||
- Specifying a custom certificate via the `TOWER_CERTIFICATE` environment variable no longer works.
|
- Specifying a custom certificate via the `TOWER_CERTIFICATE` environment variable no longer works.
|
||||||
@@ -100,9 +101,9 @@ The following notes are changes that may require changes to playbooks:
|
|||||||
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
|
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
|
||||||
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
|
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
|
||||||
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
|
- `tower_job_template` no longer supports the deprecated `extra_vars_path` parameter, please use `extra_vars` with the lookup plugin to replace this functionality.
|
||||||
- The `notification_configuration` parameter of `tower_notification` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
|
- The `notification_configuration` parameter of `tower_notification_template` has changed from a string to a dict. Please use the `lookup` plugin to read an existing file into a dict.
|
||||||
- `tower_credential` no longer supports passing a file name to ssh_key_data.
|
- `tower_credential` no longer supports passing a file name to ssh_key_data.
|
||||||
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification` module.
|
- The HipChat `notification_type` has been removed and can no longer be created using the `tower_notification_template` module.
|
||||||
|
|
||||||
{% if collection_package | lower() == "awx" %}
|
{% if collection_package | lower() == "awx" %}
|
||||||
## Running Unit Tests
|
## Running Unit Tests
|
||||||
|
|||||||
Reference in New Issue
Block a user