mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #10532 from shanemcd/downstream-fixes
Downstream fixes Reviewed-by: Elijah DeLee <kdelee@redhat.com> Reviewed-by: Alan Rominger <arominge@redhat.com>
This commit is contained in:
commit
dc7bd73431
@ -710,8 +710,12 @@ class ExecutionEnvironmentDetail(RetrieveUpdateDestroyAPIView):
|
||||
fields_to_check = ['name', 'description', 'organization', 'image', 'credential']
|
||||
if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance):
|
||||
for field in fields_to_check:
|
||||
if kwargs.get('partial') and field not in request.data:
|
||||
continue
|
||||
left = getattr(instance, field, None)
|
||||
right = request.data.get(field, None)
|
||||
if hasattr(left, 'id'):
|
||||
left = left.id
|
||||
right = request.data.get(field)
|
||||
if left != right:
|
||||
raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments."))
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@ -692,6 +692,15 @@ register(
|
||||
unit=_('seconds'),
|
||||
)
|
||||
|
||||
register(
|
||||
'IS_K8S',
|
||||
field_class=fields.BooleanField,
|
||||
read_only=True,
|
||||
category=_('System'),
|
||||
category_slug='system',
|
||||
help_text=_('Indicates whether the instance is part of a kubernetes-based deployment.'),
|
||||
)
|
||||
|
||||
|
||||
def logging_validate(serializer, attrs):
|
||||
if not serializer.instance or not hasattr(serializer.instance, 'LOG_AGGREGATOR_HOST') or not hasattr(serializer.instance, 'LOG_AGGREGATOR_TYPE'):
|
||||
|
||||
@ -63,7 +63,15 @@ base_inputs = {
|
||||
'id': 'secret_path',
|
||||
'label': _('Path to Secret'),
|
||||
'type': 'string',
|
||||
'help_text': _('The path to the secret stored in the secret backend e.g, /some/secret/'),
|
||||
'help_text': _(
|
||||
(
|
||||
'The path to the secret stored in the secret backend e.g, /some/secret/. It is recommended'
|
||||
' that you use the secret backend field to identify the storage backend and to use this field'
|
||||
' for locating a specific secret within that store. However, if you prefer to fully identify'
|
||||
' both the secret backend and one of its secrets using only this field, join their locations'
|
||||
' into a single path without any additional separators, e.g, /location/of/backend/some/secret.'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
'id': 'auth_path',
|
||||
|
||||
@ -1246,7 +1246,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
|
||||
|
||||
@property
|
||||
def preferred_instance_groups(self):
|
||||
return self.global_instance_groups
|
||||
return self.control_plane_instance_group
|
||||
|
||||
'''
|
||||
JobNotificationMixin
|
||||
|
||||
@ -1437,7 +1437,12 @@ class UnifiedJob(
|
||||
def global_instance_groups(self):
|
||||
from awx.main.models.ha import InstanceGroup
|
||||
|
||||
default_instance_groups = InstanceGroup.objects.filter(name__in=[settings.DEFAULT_EXECUTION_QUEUE_NAME, settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME])
|
||||
default_instance_group_names = [settings.DEFAULT_EXECUTION_QUEUE_NAME]
|
||||
|
||||
if not settings.IS_K8S:
|
||||
default_instance_group_names.append(settings.DEFAULT_CONTROL_PLANE_QUEUE_NAME)
|
||||
|
||||
default_instance_groups = InstanceGroup.objects.filter(name__in=default_instance_group_names)
|
||||
|
||||
return list(default_instance_groups)
|
||||
|
||||
|
||||
@ -87,7 +87,7 @@ from awx.main.exceptions import AwxTaskError, PostRunError
|
||||
from awx.main.queue import CallbackQueueDispatcher
|
||||
from awx.main.dispatch.publish import task
|
||||
from awx.main.dispatch import get_local_queuename, reaper
|
||||
from awx.main.utils import (
|
||||
from awx.main.utils.common import (
|
||||
update_scm_url,
|
||||
ignore_inventory_computed_fields,
|
||||
ignore_inventory_group_removal,
|
||||
@ -97,6 +97,7 @@ from awx.main.utils import (
|
||||
deepmerge,
|
||||
parse_yaml_or_json,
|
||||
cleanup_new_process,
|
||||
create_partition,
|
||||
)
|
||||
from awx.main.utils.execution_environments import get_default_pod_spec, CONTAINER_ROOT, to_container_path
|
||||
from awx.main.utils.ansible import read_ansible_config
|
||||
@ -1267,11 +1268,17 @@ class BaseTask(object):
|
||||
for k, v in self.safe_env.items():
|
||||
if k in job_env:
|
||||
job_env[k] = v
|
||||
self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env)
|
||||
from awx.main.signals import disable_activity_stream # Circular import
|
||||
|
||||
with disable_activity_stream():
|
||||
self.instance = self.update_model(self.instance.pk, job_args=json.dumps(runner_config.command), job_cwd=runner_config.cwd, job_env=job_env)
|
||||
elif status_data['status'] == 'error':
|
||||
result_traceback = status_data.get('result_traceback', None)
|
||||
if result_traceback:
|
||||
self.instance = self.update_model(self.instance.pk, result_traceback=result_traceback)
|
||||
from awx.main.signals import disable_activity_stream # Circular import
|
||||
|
||||
with disable_activity_stream():
|
||||
self.instance = self.update_model(self.instance.pk, result_traceback=result_traceback)
|
||||
|
||||
@with_path_cleanup
|
||||
def run(self, pk, **kwargs):
|
||||
@ -1791,6 +1798,7 @@ class RunJob(BaseTask):
|
||||
if 'update_' not in sync_metafields['job_tags']:
|
||||
sync_metafields['scm_revision'] = job_revision
|
||||
local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields)
|
||||
create_partition(local_project_sync.event_class._meta.db_table, start=local_project_sync.created)
|
||||
# save the associated job before calling run() so that a
|
||||
# cancel() call on the job can cancel the project update
|
||||
job = self.update_model(job.pk, project_update=local_project_sync)
|
||||
@ -2070,17 +2078,24 @@ class RunProjectUpdate(BaseTask):
|
||||
if InventoryUpdate.objects.filter(inventory_source=inv_src, status__in=ACTIVE_STATES).exists():
|
||||
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
|
||||
continue
|
||||
|
||||
if settings.IS_K8S:
|
||||
instance_group = InventoryUpdate(inventory_source=inv_src).preferred_instance_groups[0]
|
||||
else:
|
||||
instance_group = project_update.instance_group
|
||||
|
||||
local_inv_update = inv_src.create_inventory_update(
|
||||
_eager_fields=dict(
|
||||
launch_type='scm',
|
||||
status='running',
|
||||
instance_group=project_update.instance_group,
|
||||
instance_group=instance_group,
|
||||
execution_node=project_update.execution_node,
|
||||
source_project_update=project_update,
|
||||
celery_task_id=project_update.celery_task_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
|
||||
inv_update_class().run(local_inv_update.id)
|
||||
except Exception:
|
||||
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
|
||||
@ -2161,8 +2176,6 @@ class RunProjectUpdate(BaseTask):
|
||||
if not os.path.exists(settings.PROJECTS_ROOT):
|
||||
os.mkdir(settings.PROJECTS_ROOT)
|
||||
project_path = instance.project.get_project_path(check_if_exists=False)
|
||||
if not os.path.exists(project_path):
|
||||
os.makedirs(project_path) # used as container mount
|
||||
|
||||
self.acquire_lock(instance)
|
||||
|
||||
@ -2175,6 +2188,9 @@ class RunProjectUpdate(BaseTask):
|
||||
else:
|
||||
self.original_branch = git_repo.active_branch
|
||||
|
||||
if not os.path.exists(project_path):
|
||||
os.makedirs(project_path) # used as container mount
|
||||
|
||||
stage_path = os.path.join(instance.get_cache_path(), 'stage')
|
||||
if os.path.exists(stage_path):
|
||||
logger.warning('{0} unexpectedly existed before update'.format(stage_path))
|
||||
@ -2988,7 +3004,8 @@ class AWXReceptorJob:
|
||||
if state_name == 'Succeeded':
|
||||
return res
|
||||
|
||||
raise RuntimeError(detail)
|
||||
if self.task.instance.result_traceback is None:
|
||||
raise RuntimeError(detail)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ def mk_instance(persisted=True, hostname='instance.example.org'):
|
||||
return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, hostname=hostname)[0]
|
||||
|
||||
|
||||
def mk_instance_group(name='tower', instance=None, minimum=0, percentage=0):
|
||||
def mk_instance_group(name='default', instance=None, minimum=0, percentage=0):
|
||||
ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, policy_instance_percentage=percentage)
|
||||
if instance is not None:
|
||||
if type(instance) == list:
|
||||
|
||||
@ -35,18 +35,19 @@ class TestDependentInventoryUpdate:
|
||||
task.post_run_hook(proj_update, 'successful')
|
||||
assert not inv_update_mck.called
|
||||
|
||||
def test_dependent_inventory_updates(self, scm_inventory_source):
|
||||
def test_dependent_inventory_updates(self, scm_inventory_source, default_instance_group):
|
||||
task = RunProjectUpdate()
|
||||
scm_inventory_source.scm_last_revision = ''
|
||||
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
|
||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
||||
task._update_dependent_inventories(proj_update, [scm_inventory_source])
|
||||
assert InventoryUpdate.objects.count() == 1
|
||||
inv_update = InventoryUpdate.objects.first()
|
||||
iu_run_mock.assert_called_once_with(inv_update.id)
|
||||
assert inv_update.source_project_update_id == proj_update.pk
|
||||
with mock.patch('awx.main.tasks.create_partition'):
|
||||
task._update_dependent_inventories(proj_update, [scm_inventory_source])
|
||||
assert InventoryUpdate.objects.count() == 1
|
||||
inv_update = InventoryUpdate.objects.first()
|
||||
iu_run_mock.assert_called_once_with(inv_update.id)
|
||||
assert inv_update.source_project_update_id == proj_update.pk
|
||||
|
||||
def test_dependent_inventory_project_cancel(self, project, inventory):
|
||||
def test_dependent_inventory_project_cancel(self, project, inventory, default_instance_group):
|
||||
"""
|
||||
Test that dependent inventory updates exhibit good behavior on cancel
|
||||
of the source project update
|
||||
@ -63,8 +64,9 @@ class TestDependentInventoryUpdate:
|
||||
ProjectUpdate.objects.all().update(cancel_flag=True)
|
||||
|
||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
||||
iu_run_mock.side_effect = user_cancels_project
|
||||
task._update_dependent_inventories(proj_update, [is1, is2])
|
||||
# Verify that it bails after 1st update, detecting a cancel
|
||||
assert is2.inventory_updates.count() == 0
|
||||
iu_run_mock.assert_called_once()
|
||||
with mock.patch('awx.main.tasks.create_partition'):
|
||||
iu_run_mock.side_effect = user_cancels_project
|
||||
task._update_dependent_inventories(proj_update, [is1, is2])
|
||||
# Verify that it bails after 1st update, detecting a cancel
|
||||
assert is2.inventory_updates.count() == 0
|
||||
iu_run_mock.assert_called_once()
|
||||
|
||||
@ -63,6 +63,7 @@
|
||||
"aria-labelledby",
|
||||
"aria-hidden",
|
||||
"aria-controls",
|
||||
"aria-pressed",
|
||||
"sortKey",
|
||||
"ouiaId",
|
||||
"credentialTypeNamespace",
|
||||
|
||||
623
awx/ui_next/package-lock.json
generated
623
awx/ui_next/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,12 @@
|
||||
function isEqual(array1, array2) {
|
||||
return (
|
||||
array1.length === array2.length &&
|
||||
array1.every((element, index) => {
|
||||
return element.id === array2[index].id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const InstanceGroupsMixin = parent =>
|
||||
class extends parent {
|
||||
readInstanceGroups(resourceId, params) {
|
||||
@ -18,6 +27,20 @@ const InstanceGroupsMixin = parent =>
|
||||
disassociate: true,
|
||||
});
|
||||
}
|
||||
|
||||
async orderInstanceGroups(resourceId, current, original) {
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||
if (!isEqual(current, original)) {
|
||||
for (const group of original) {
|
||||
await this.disassociateInstanceGroup(resourceId, group.id);
|
||||
}
|
||||
for (const group of current) {
|
||||
await this.associateInstanceGroup(resourceId, group.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
};
|
||||
|
||||
export default InstanceGroupsMixin;
|
||||
|
||||
@ -14,6 +14,10 @@ class Settings extends Base {
|
||||
return this.http.patch(`${this.baseUrl}all/`, data);
|
||||
}
|
||||
|
||||
readAll() {
|
||||
return this.http.get(`${this.baseUrl}all/`);
|
||||
}
|
||||
|
||||
updateCategory(category, data) {
|
||||
return this.http.patch(`${this.baseUrl}${category}/`, data);
|
||||
}
|
||||
|
||||
7
awx/ui_next/src/border.css
Normal file
7
awx/ui_next/src/border.css
Normal file
@ -0,0 +1,7 @@
|
||||
.pf-c-select__toggle:before {
|
||||
border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid var(--pf-c-select__toggle--before--BorderTopColor);
|
||||
border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid var(--pf-c-select__toggle--before--BorderRightColor);
|
||||
border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid var(--pf-c-select__toggle--before--BorderBottomColor);
|
||||
border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid var(--pf-c-select__toggle--before--BorderLeftColor);
|
||||
}
|
||||
/* https://github.com/patternfly/patternfly-react/issues/5650 */
|
||||
@ -6,7 +6,7 @@ import useRequest from '../../util/useRequest';
|
||||
import { SearchColumns, SortColumns } from '../../types';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
import CheckboxListItem from '../CheckboxListItem';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { SelectedList } from '../SelectedList';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import CheckboxCard from './CheckboxCard';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { SelectedList } from '../SelectedList';
|
||||
|
||||
function RolesStep({
|
||||
onRolesClick,
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Checkbox,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarContent as PFToolbarContent,
|
||||
ToolbarGroup,
|
||||
ToolbarItem,
|
||||
ToolbarToggleGroup,
|
||||
Dropdown,
|
||||
DropdownPosition,
|
||||
KebabToggle,
|
||||
} from '@patternfly/react-core';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
@ -19,6 +21,12 @@ import Sort from '../Sort';
|
||||
import { SearchColumns, SortColumns, QSConfig } from '../../types';
|
||||
import { KebabifiedProvider } from '../../contexts/Kebabified';
|
||||
|
||||
const ToolbarContent = styled(PFToolbarContent)`
|
||||
& > .pf-c-toolbar__content-section {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
function DataListToolbar({
|
||||
itemCount,
|
||||
clearAllFilters,
|
||||
@ -47,6 +55,11 @@ function DataListToolbar({
|
||||
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
|
||||
const [isAdvancedSearchShown, setIsAdvancedSearchShown] = useState(false);
|
||||
|
||||
const viewportWidth =
|
||||
window.innerWidth || document.documentElement.clientWidth;
|
||||
const dropdownPosition =
|
||||
viewportWidth >= 992 ? DropdownPosition.right : DropdownPosition.left;
|
||||
|
||||
const onShowAdvancedSearch = shown => {
|
||||
setIsAdvancedSearchShown(shown);
|
||||
setIsKebabOpen(false);
|
||||
@ -135,6 +148,7 @@ function DataListToolbar({
|
||||
/>
|
||||
}
|
||||
isOpen={isKebabOpen}
|
||||
position={dropdownPosition}
|
||||
isPlain
|
||||
dropdownItems={additionalControls}
|
||||
/>
|
||||
|
||||
@ -12,7 +12,15 @@ import {
|
||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||
|
||||
function PasswordInput(props) {
|
||||
const { autocomplete, id, name, validate, isRequired, isDisabled } = props;
|
||||
const {
|
||||
autocomplete,
|
||||
id,
|
||||
name,
|
||||
validate,
|
||||
isFieldGroupValid,
|
||||
isRequired,
|
||||
isDisabled,
|
||||
} = props;
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [field, meta] = useField({ name, validate });
|
||||
|
||||
@ -44,7 +52,7 @@ function PasswordInput(props) {
|
||||
value={field.value === '$encrypted$' ? '' : field.value}
|
||||
isDisabled={isDisabled}
|
||||
isRequired={isRequired}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
validated={isValid || isFieldGroupValid ? 'default' : 'error'}
|
||||
type={inputType}
|
||||
onChange={(_, event) => {
|
||||
field.onChange(event);
|
||||
|
||||
@ -39,6 +39,7 @@ const InventoryLookupField = ({ isDisabled }) => {
|
||||
error={inventoryMeta.error}
|
||||
validate={required(t`Select a value for this field`)}
|
||||
isDisabled={isDisabled}
|
||||
hideSmartInventories
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@ -29,22 +29,24 @@ const QS_CONFIG = getQSConfig('credentials', {
|
||||
});
|
||||
|
||||
function CredentialLookup({
|
||||
helperTextInvalid,
|
||||
label,
|
||||
isValid,
|
||||
onBlur,
|
||||
onChange,
|
||||
required,
|
||||
autoPopulate,
|
||||
credentialTypeId,
|
||||
credentialTypeKind,
|
||||
credentialTypeNamespace,
|
||||
value,
|
||||
tooltip,
|
||||
isDisabled,
|
||||
autoPopulate,
|
||||
multiple,
|
||||
validate,
|
||||
fieldName,
|
||||
helperTextInvalid,
|
||||
isDisabled,
|
||||
isSelectedDraggable,
|
||||
isValid,
|
||||
label,
|
||||
modalDescription,
|
||||
multiple,
|
||||
onBlur,
|
||||
onChange,
|
||||
required,
|
||||
tooltip,
|
||||
validate,
|
||||
value,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||
@ -174,6 +176,7 @@ function CredentialLookup({
|
||||
qsConfig={QS_CONFIG}
|
||||
isDisabled={isDisabled}
|
||||
multiple={multiple}
|
||||
modalDescription={modalDescription}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
@ -208,7 +211,11 @@ function CredentialLookup({
|
||||
name="credential"
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
sortSelectedItems={selectedItems =>
|
||||
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
|
||||
}
|
||||
multiple={multiple}
|
||||
isSelectedDraggable={isSelectedDraggable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -41,7 +41,7 @@ function ExecutionEnvironmentLookup({
|
||||
const {
|
||||
request: fetchProject,
|
||||
error: fetchProjectError,
|
||||
isLoading: fetchProjectLoading,
|
||||
isLoading: isProjectLoading,
|
||||
result: project,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
@ -53,6 +53,7 @@ function ExecutionEnvironmentLookup({
|
||||
}, [projectId]),
|
||||
{
|
||||
project: null,
|
||||
isLoading: true,
|
||||
}
|
||||
);
|
||||
|
||||
@ -72,6 +73,12 @@ function ExecutionEnvironmentLookup({
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
if (isProjectLoading) {
|
||||
return {
|
||||
executionEnvironments: [],
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const globallyAvailableParams = globallyAvailable
|
||||
? { or__organization__isnull: 'True' }
|
||||
@ -105,7 +112,14 @@ function ExecutionEnvironmentLookup({
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location, globallyAvailable, organizationId, projectId, project]),
|
||||
}, [
|
||||
location,
|
||||
globallyAvailable,
|
||||
organizationId,
|
||||
projectId,
|
||||
project,
|
||||
isProjectLoading,
|
||||
]),
|
||||
{
|
||||
executionEnvironments: [],
|
||||
count: 0,
|
||||
@ -149,7 +163,7 @@ function ExecutionEnvironmentLookup({
|
||||
fieldName={fieldName}
|
||||
validate={validate}
|
||||
qsConfig={QS_CONFIG}
|
||||
isLoading={isLoading || fetchProjectLoading}
|
||||
isLoading={isLoading || isProjectLoading}
|
||||
isDisabled={isDisabled}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
|
||||
@ -30,9 +30,9 @@ describe('ExecutionEnvironmentLookup', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
ExecutionEnvironmentsAPI.read.mockResolvedValue(
|
||||
mockedExecutionEnvironments
|
||||
);
|
||||
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||
data: mockedExecutionEnvironments,
|
||||
});
|
||||
ProjectsAPI.readDetail.mockResolvedValue({ data: { organization: 39 } });
|
||||
});
|
||||
|
||||
@ -63,7 +63,7 @@ describe('ExecutionEnvironmentLookup', () => {
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2);
|
||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
||||
@ -84,7 +84,7 @@ describe('ExecutionEnvironmentLookup', () => {
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2);
|
||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
||||
).toBe(0);
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
|
||||
import { arrayOf, string, func, bool } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { FormGroup } from '@patternfly/react-core';
|
||||
import { InstanceGroupsAPI } from '../../api';
|
||||
import { InstanceGroup } from '../../types';
|
||||
@ -82,6 +82,18 @@ function InstanceGroupsLookup({
|
||||
multiple
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
modalDescription={
|
||||
<>
|
||||
<b>
|
||||
<Trans>Selected</Trans>
|
||||
</b>
|
||||
<br />
|
||||
<Trans>
|
||||
Note: The order in which these are selected sets the execution
|
||||
precedence.
|
||||
</Trans>
|
||||
</>
|
||||
}
|
||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||
<OptionsList
|
||||
value={state.selectedItems}
|
||||
@ -113,6 +125,10 @@ function InstanceGroupsLookup({
|
||||
readOnly={!canDelete}
|
||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||
sortSelectedItems={selectedItems =>
|
||||
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
|
||||
}
|
||||
isSelectedDraggable
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -7,7 +7,7 @@ import { Inventory } from '../../types';
|
||||
import Lookup from './Lookup';
|
||||
import OptionsList from '../OptionsList';
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
import FieldWithPrompt from '../FieldWithPrompt';
|
||||
|
||||
@ -32,6 +32,7 @@ function InventoryLookup({
|
||||
validate,
|
||||
fieldName,
|
||||
isDisabled,
|
||||
hideSmartInventories,
|
||||
}) {
|
||||
const {
|
||||
result: {
|
||||
@ -47,8 +48,15 @@ function InventoryLookup({
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||
const inventoryKindParams = hideSmartInventories
|
||||
? { not__kind: 'smart' }
|
||||
: {};
|
||||
const [{ data }, actionsResponse] = await Promise.all([
|
||||
InventoriesAPI.read(params),
|
||||
InventoriesAPI.read(
|
||||
mergeParams(params, {
|
||||
...inventoryKindParams,
|
||||
})
|
||||
),
|
||||
InventoriesAPI.readOptions(),
|
||||
]);
|
||||
|
||||
@ -60,7 +68,12 @@ function InventoryLookup({
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
actionsResponse.data.actions?.GET || {}
|
||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||
).filter(key => {
|
||||
if (['kind', 'host_filter'].includes(key) && hideSmartInventories) {
|
||||
return false;
|
||||
}
|
||||
return actionsResponse.data.actions?.GET[key].filterable;
|
||||
}),
|
||||
canEdit:
|
||||
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||
};
|
||||
@ -230,6 +243,7 @@ InventoryLookup.propTypes = {
|
||||
validate: func,
|
||||
fieldName: string,
|
||||
isDisabled: bool,
|
||||
hideSmartInventories: bool,
|
||||
};
|
||||
|
||||
InventoryLookup.defaultProps = {
|
||||
@ -239,6 +253,7 @@ InventoryLookup.defaultProps = {
|
||||
validate: () => {},
|
||||
fieldName: 'inventory',
|
||||
isDisabled: false,
|
||||
hideSmartInventories: false,
|
||||
};
|
||||
|
||||
export default withRouter(InventoryLookup);
|
||||
|
||||
@ -48,6 +48,42 @@ describe('InventoryLookup', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.read).toHaveBeenCalledWith({
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
role_level: 'use_role',
|
||||
});
|
||||
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
|
||||
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
test('should fetch only regular inventories when hideSmartInventories is true', async () => {
|
||||
InventoriesAPI.readOptions.mockReturnValue({
|
||||
data: {
|
||||
actions: {
|
||||
GET: {},
|
||||
POST: {},
|
||||
},
|
||||
related_search_fields: [],
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Formik>
|
||||
<InventoryLookup onChange={() => {}} hideSmartInventories />
|
||||
</Formik>
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.read).toHaveBeenCalledWith({
|
||||
not__kind: 'smart',
|
||||
order_by: 'name',
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
role_level: 'use_role',
|
||||
});
|
||||
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
|
||||
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
@ -49,6 +49,7 @@ function Lookup(props) {
|
||||
onDebounce,
|
||||
fieldName,
|
||||
validate,
|
||||
modalDescription,
|
||||
} = props;
|
||||
const [typedText, setTypedText] = useState('');
|
||||
const debounceRequest = useDebounce(onDebounce, 1000);
|
||||
@ -166,6 +167,7 @@ function Lookup(props) {
|
||||
aria-label={t`Lookup modal`}
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
description={state?.selectedItems?.length > 0 && modalDescription}
|
||||
actions={[
|
||||
<Button
|
||||
ouiaId="modal-select-button"
|
||||
@ -204,6 +206,7 @@ const Item = shape({
|
||||
Lookup.propTypes = {
|
||||
id: string,
|
||||
header: string,
|
||||
modalDescription: string,
|
||||
onChange: func.isRequired,
|
||||
value: oneOfType([Item, arrayOf(Item)]),
|
||||
multiple: bool,
|
||||
@ -224,6 +227,7 @@ Lookup.defaultProps = {
|
||||
value: null,
|
||||
multiple: false,
|
||||
required: false,
|
||||
modalDescription: '',
|
||||
onBlur: () => {},
|
||||
renderItemChip: ({ item, removeItem, canDelete }) => (
|
||||
<Chip
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from 'prop-types';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import SelectedList from '../SelectedList';
|
||||
import { SelectedList, DraggableSelectedList } from '../SelectedList';
|
||||
import CheckboxListItem from '../CheckboxListItem';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
||||
@ -23,28 +23,39 @@ const ModalList = styled.div`
|
||||
`;
|
||||
|
||||
function OptionsList({
|
||||
value,
|
||||
contentError,
|
||||
options,
|
||||
optionCount,
|
||||
searchColumns,
|
||||
sortColumns,
|
||||
searchableKeys,
|
||||
relatedSearchableKeys,
|
||||
multiple,
|
||||
deselectItem,
|
||||
displayKey,
|
||||
header,
|
||||
isLoading,
|
||||
isSelectedDraggable,
|
||||
multiple,
|
||||
name,
|
||||
optionCount,
|
||||
options,
|
||||
qsConfig,
|
||||
readOnly,
|
||||
selectItem,
|
||||
deselectItem,
|
||||
relatedSearchableKeys,
|
||||
renderItemChip,
|
||||
isLoading,
|
||||
displayKey,
|
||||
searchColumns,
|
||||
searchableKeys,
|
||||
selectItem,
|
||||
sortColumns,
|
||||
sortSelectedItems,
|
||||
value,
|
||||
}) {
|
||||
return (
|
||||
<ModalList>
|
||||
{value.length > 0 && (
|
||||
let selectionPreview = null;
|
||||
if (value.length > 0) {
|
||||
if (isSelectedDraggable) {
|
||||
selectionPreview = (
|
||||
<DraggableSelectedList
|
||||
onRemove={deselectItem}
|
||||
onRowDrag={sortSelectedItems}
|
||||
selected={value}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
selectionPreview = (
|
||||
<SelectedList
|
||||
label={t`Selected`}
|
||||
selected={value}
|
||||
@ -53,7 +64,13 @@ function OptionsList({
|
||||
renderItemChip={renderItemChip}
|
||||
displayKey={displayKey}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalList>
|
||||
{selectionPreview}
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
items={options}
|
||||
@ -99,6 +116,7 @@ const Item = shape({
|
||||
OptionsList.propTypes = {
|
||||
deselectItem: func.isRequired,
|
||||
displayKey: string,
|
||||
isSelectedDraggable: bool,
|
||||
multiple: bool,
|
||||
optionCount: number.isRequired,
|
||||
options: arrayOf(Item).isRequired,
|
||||
@ -110,6 +128,7 @@ OptionsList.propTypes = {
|
||||
value: arrayOf(Item).isRequired,
|
||||
};
|
||||
OptionsList.defaultProps = {
|
||||
isSelectedDraggable: false,
|
||||
multiple: false,
|
||||
renderItemChip: null,
|
||||
searchColumns: [],
|
||||
|
||||
@ -24,6 +24,10 @@ const AdvancedGroup = styled.div`
|
||||
display: grid;
|
||||
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
|
||||
}
|
||||
|
||||
& .pf-c-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
`;
|
||||
|
||||
function AdvancedSearch({
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
DataList,
|
||||
DataListAction,
|
||||
DataListItem,
|
||||
DataListCell,
|
||||
DataListItemRow,
|
||||
DataListControl,
|
||||
DataListDragButton,
|
||||
DataListItemCells,
|
||||
} from '@patternfly/react-core';
|
||||
import { TimesIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
const RemoveActionSection = styled(DataListAction)`
|
||||
&& {
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
function DraggableSelectedList({ selected, onRemove, onRowDrag }) {
|
||||
const [liveText, setLiveText] = useState('');
|
||||
const [id, setId] = useState('');
|
||||
|
||||
const onDragStart = newId => {
|
||||
setId(newId);
|
||||
setLiveText(t`Dragging started for item id: ${newId}.`);
|
||||
};
|
||||
|
||||
const onDragMove = (oldIndex, newIndex) => {
|
||||
setLiveText(
|
||||
t`Dragging item ${id}. Item with index ${oldIndex} in now ${newIndex}.`
|
||||
);
|
||||
};
|
||||
|
||||
const onDragCancel = () => {
|
||||
setLiveText(t`Dragging cancelled. List is unchanged.`);
|
||||
};
|
||||
|
||||
const onDragFinish = newItemOrder => {
|
||||
const selectedItems = newItemOrder.map(item =>
|
||||
selected.find(i => i.name === item)
|
||||
);
|
||||
onRowDrag(selectedItems);
|
||||
};
|
||||
|
||||
const removeItem = item => {
|
||||
onRemove(selected.find(i => i.name === item));
|
||||
};
|
||||
|
||||
if (selected.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const orderedList = selected.map(item => item.name);
|
||||
return (
|
||||
<>
|
||||
<DataList
|
||||
aria-label={t`Draggable list to reorder and remove selected items.`}
|
||||
data-cy="draggable-list"
|
||||
itemOrder={orderedList}
|
||||
onDragCancel={onDragCancel}
|
||||
onDragFinish={onDragFinish}
|
||||
onDragMove={onDragMove}
|
||||
onDragStart={onDragStart}
|
||||
>
|
||||
{orderedList.map((label, index) => {
|
||||
const rowPosition = index + 1;
|
||||
return (
|
||||
<DataListItem id={label} key={rowPosition}>
|
||||
<DataListItemRow>
|
||||
<DataListControl>
|
||||
<DataListDragButton
|
||||
aria-label={t`Reorder`}
|
||||
aria-labelledby={rowPosition}
|
||||
aria-describedby={t`Press space or enter to begin dragging,
|
||||
and use the arrow keys to navigate up or down.
|
||||
Press enter to confirm the drag, or any other key to
|
||||
cancel the drag operation.`}
|
||||
aria-pressed="false"
|
||||
data-cy={`reorder-${label}`}
|
||||
/>
|
||||
</DataListControl>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key={label}>
|
||||
<span id={rowPosition}>{`${rowPosition}. ${label}`}</span>
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<RemoveActionSection aria-label={t`Actions`} id={rowPosition}>
|
||||
<Button
|
||||
onClick={() => removeItem(label)}
|
||||
variant="plain"
|
||||
aria-label={t`Remove`}
|
||||
ouiaId={`draggable-list-remove-${label}`}
|
||||
>
|
||||
<TimesIcon />
|
||||
</Button>
|
||||
</RemoveActionSection>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
})}
|
||||
</DataList>
|
||||
<div className="pf-screen-reader" aria-live="assertive">
|
||||
{liveText}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ListItem = PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
});
|
||||
DraggableSelectedList.propTypes = {
|
||||
onRemove: PropTypes.func,
|
||||
onRowDrag: PropTypes.func,
|
||||
selected: PropTypes.arrayOf(ListItem),
|
||||
};
|
||||
DraggableSelectedList.defaultProps = {
|
||||
onRemove: () => null,
|
||||
onRowDrag: () => null,
|
||||
selected: [],
|
||||
};
|
||||
|
||||
export default DraggableSelectedList;
|
||||
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import DraggableSelectedList from './DraggableSelectedList';
|
||||
|
||||
describe('<DraggableSelectedList />', () => {
|
||||
let wrapper;
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
test('should render expected rows', () => {
|
||||
const mockSelected = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'foo',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'bar',
|
||||
},
|
||||
];
|
||||
wrapper = mountWithContexts(
|
||||
<DraggableSelectedList
|
||||
selected={mockSelected}
|
||||
onRemove={() => {}}
|
||||
onRowDrag={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('DraggableSelectedList').length).toBe(1);
|
||||
expect(wrapper.find('DataListItem').length).toBe(2);
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListItem DataListCell')
|
||||
.first()
|
||||
.containsMatchingElement(<span>1. foo</span>)
|
||||
).toEqual(true);
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListItem DataListCell')
|
||||
.last()
|
||||
.containsMatchingElement(<span>2. bar</span>)
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
test('should not render when selected list is empty', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<DraggableSelectedList
|
||||
selected={[]}
|
||||
onRemove={() => {}}
|
||||
onRowDrag={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('DataList').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should call onRemove callback prop on remove button click', () => {
|
||||
const onRemove = jest.fn();
|
||||
const mockSelected = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'foo',
|
||||
},
|
||||
];
|
||||
wrapper = mountWithContexts(
|
||||
<DraggableSelectedList selected={mockSelected} onRemove={onRemove} />
|
||||
);
|
||||
wrapper
|
||||
.find('DataListItem[id="foo"] Button[aria-label="Remove"]')
|
||||
.simulate('click');
|
||||
expect(onRemove).toBeCalledWith({
|
||||
id: 1,
|
||||
name: 'foo',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1 +1,2 @@
|
||||
export { default } from './SelectedList';
|
||||
export { default as SelectedList } from './SelectedList';
|
||||
export { default as DraggableSelectedList } from './DraggableSelectedList';
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './setupCSP';
|
||||
import '@patternfly/react-core/dist/styles/base.css';
|
||||
import './border.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,12 @@ const FileUpload = styled(PFFileUpload)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
|
||||
function CredentialInput({
|
||||
fieldOptions,
|
||||
isFieldGroupValid,
|
||||
credentialKind,
|
||||
...rest
|
||||
}) {
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [fileIsUploading, setFileIsUploading] = useState(false);
|
||||
const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
||||
@ -116,6 +121,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
|
||||
<>
|
||||
{RevertReplaceButton}
|
||||
<PasswordInput
|
||||
isFieldGroupValid={isFieldGroupValid}
|
||||
{...subFormField}
|
||||
id={`credential-${fieldOptions.id}`}
|
||||
{...rest}
|
||||
@ -169,7 +175,9 @@ function CredentialField({ credentialType, fieldOptions }) {
|
||||
name: `inputs.${fieldOptions.id}`,
|
||||
validate: validateField(),
|
||||
});
|
||||
const isValid = !(meta.touched && meta.error);
|
||||
const isValid =
|
||||
!(meta.touched && meta.error) ||
|
||||
formikValues.passwordPrompts[fieldOptions.id];
|
||||
|
||||
if (fieldOptions.choices) {
|
||||
const selectOptions = fieldOptions.choices.map(choice => {
|
||||
@ -235,7 +243,10 @@ function CredentialField({ credentialType, fieldOptions }) {
|
||||
isRequired={isRequired}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<CredentialInput fieldOptions={fieldOptions} />
|
||||
<CredentialInput
|
||||
isFieldGroupValid={isValid}
|
||||
fieldOptions={fieldOptions}
|
||||
/>
|
||||
</CredentialPluginField>
|
||||
);
|
||||
}
|
||||
|
||||
@ -98,15 +98,23 @@ function CredentialPluginField(props) {
|
||||
const [, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
||||
const [passwordPromptField] = useField(`passwordPrompts.${fieldOptions.id}`);
|
||||
|
||||
const invalidHelperTextToDisplay = meta.error && meta.touched && (
|
||||
<div
|
||||
className={css(styles.formHelperText, styles.modifiers.error)}
|
||||
id={`${fieldOptions.id}-helper`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{meta.error}
|
||||
</div>
|
||||
);
|
||||
let invalidHelperTextToDisplay;
|
||||
|
||||
if (meta.error && meta.touched) {
|
||||
invalidHelperTextToDisplay = (
|
||||
<div
|
||||
className={css(styles.formHelperText, styles.modifiers.error)}
|
||||
id={`${fieldOptions.id}-helper`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{meta.error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldOptions.id === 'vault_password' && passwordPromptField.value) {
|
||||
invalidHelperTextToDisplay = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (passwordPromptField.value) {
|
||||
@ -119,8 +127,6 @@ function CredentialPluginField(props) {
|
||||
<>
|
||||
{fieldOptions.ask_at_runtime ? (
|
||||
<FieldWithPrompt
|
||||
fieldId={`credential-${fieldOptions.id}`}
|
||||
helperTextInvalid={meta.error}
|
||||
isRequired={isRequired}
|
||||
label={fieldOptions.label}
|
||||
promptId={`credential-prompt-${fieldOptions.id}`}
|
||||
|
||||
@ -74,7 +74,10 @@ function ExecutionEnvironmentFormFields({
|
||||
: null
|
||||
}
|
||||
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
|
||||
isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current}
|
||||
isDisabled={
|
||||
(!!isOrgLookupDisabled && isGloballyAvailable.current) ||
|
||||
executionEnvironment?.managed
|
||||
}
|
||||
validate={
|
||||
!me?.is_superuser
|
||||
? required(t`Select a value for this field`)
|
||||
@ -93,6 +96,7 @@ function ExecutionEnvironmentFormFields({
|
||||
type="text"
|
||||
validate={required(null)}
|
||||
isRequired
|
||||
isDisabled={executionEnvironment?.managed || false}
|
||||
/>
|
||||
<FormField
|
||||
id="execution-environment-image"
|
||||
@ -101,6 +105,7 @@ function ExecutionEnvironmentFormFields({
|
||||
type="text"
|
||||
validate={required(null)}
|
||||
isRequired
|
||||
isDisabled={executionEnvironment?.managed || false}
|
||||
tooltip={
|
||||
<span>
|
||||
{t`The full image location, including the container registry, image name, and version tag.`}
|
||||
@ -142,6 +147,7 @@ function ExecutionEnvironmentFormFields({
|
||||
label={t`Description`}
|
||||
name="description"
|
||||
type="text"
|
||||
isDisabled={executionEnvironment?.managed || false}
|
||||
/>
|
||||
{isOrgLookupDisabled && isGloballyAvailable.current ? (
|
||||
<Tooltip
|
||||
@ -162,6 +168,7 @@ function ExecutionEnvironmentFormFields({
|
||||
onChange={onCredentialChange}
|
||||
value={credentialField.value}
|
||||
tooltip={t`Credential to authenticate with a protected container registry.`}
|
||||
isDisabled={executionEnvironment?.managed || false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -271,4 +271,46 @@ describe('<ExecutionEnvironmentForm/>', () => {
|
||||
newWrapper.update();
|
||||
expect(newWrapper.find('OrganizationLookup').prop('value')).toEqual(null);
|
||||
});
|
||||
|
||||
test('should disable edition for managed EEs, except pull option', async () => {
|
||||
let newWrapper;
|
||||
await act(async () => {
|
||||
newWrapper = mountWithContexts(
|
||||
<ExecutionEnvironmentForm
|
||||
onCancel={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
executionEnvironment={{ ...executionEnvironment, managed: true }}
|
||||
options={mockOptions}
|
||||
me={mockMe}
|
||||
/>
|
||||
);
|
||||
});
|
||||
await waitForElement(newWrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(newWrapper.find('OrganizationLookup').prop('isDisabled')).toEqual(
|
||||
true
|
||||
);
|
||||
expect(newWrapper.find('CredentialLookup').prop('isDisabled')).toEqual(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
newWrapper
|
||||
.find('TextInputBase[id="execution-environment-name"]')
|
||||
.prop('isDisabled')
|
||||
).toEqual(true);
|
||||
expect(
|
||||
newWrapper
|
||||
.find('TextInputBase[id="execution-environment-description"]')
|
||||
.prop('isDisabled')
|
||||
).toEqual(true);
|
||||
expect(
|
||||
newWrapper
|
||||
.find('TextInputBase[id="execution-environment-image"]')
|
||||
.prop('isDisabled')
|
||||
).toEqual(true);
|
||||
expect(
|
||||
newWrapper
|
||||
.find('FormSelect[id="container-pull-options"]')
|
||||
.prop('isDisabled')
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { InstanceGroupsAPI } from '../../api';
|
||||
import { InstanceGroupsAPI, SettingsAPI } from '../../api';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
@ -30,12 +30,24 @@ function ContainerGroup({ setBreadcrumb }) {
|
||||
isLoading,
|
||||
error: contentError,
|
||||
request: fetchInstanceGroups,
|
||||
result: instanceGroup,
|
||||
result: { instanceGroup, defaultExecution },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InstanceGroupsAPI.readDetail(id);
|
||||
return data;
|
||||
}, [id])
|
||||
const [
|
||||
{ data },
|
||||
{
|
||||
data: { DEFAULT_EXECUTION_QUEUE_NAME },
|
||||
},
|
||||
] = await Promise.all([
|
||||
InstanceGroupsAPI.readDetail(id),
|
||||
SettingsAPI.readAll(),
|
||||
]);
|
||||
return {
|
||||
instanceGroup: data,
|
||||
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
|
||||
};
|
||||
}, [id]),
|
||||
{ instanceGroup: null, defaultExecution: '' }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -109,10 +121,16 @@ function ContainerGroup({ setBreadcrumb }) {
|
||||
{instanceGroup && (
|
||||
<>
|
||||
<Route path="/instance_groups/container_group/:id/edit">
|
||||
<ContainerGroupEdit instanceGroup={instanceGroup} />
|
||||
<ContainerGroupEdit
|
||||
instanceGroup={instanceGroup}
|
||||
defaultExecution={defaultExecution}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/container_group/:id/details">
|
||||
<ContainerGroupDetails instanceGroup={instanceGroup} />
|
||||
<ContainerGroupDetails
|
||||
instanceGroup={instanceGroup}
|
||||
defaultExecution={defaultExecution}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/container_group/:id/jobs">
|
||||
<JobList
|
||||
|
||||
@ -6,6 +6,7 @@ import { Button, Label } from '@patternfly/react-core';
|
||||
|
||||
import { VariablesDetail } from '../../../components/CodeEditor';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
import {
|
||||
@ -18,7 +19,7 @@ import { jsonToYaml, isJsonString } from '../../../util/yaml';
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||
|
||||
function ContainerGroupDetails({ instanceGroup }) {
|
||||
function ContainerGroupDetails({ instanceGroup, defaultExecution }) {
|
||||
const { id, name } = instanceGroup;
|
||||
|
||||
const history = useHistory();
|
||||
@ -102,7 +103,8 @@ function ContainerGroupDetails({ instanceGroup }) {
|
||||
{t`Edit`}
|
||||
</Button>
|
||||
)}
|
||||
{instanceGroup.summary_fields.user_capabilities &&
|
||||
{name !== defaultExecution &&
|
||||
instanceGroup.summary_fields.user_capabilities &&
|
||||
instanceGroup.summary_fields.user_capabilities.delete && (
|
||||
<DeleteButton
|
||||
ouiaId="container-group-detail-delete-button"
|
||||
@ -123,7 +125,9 @@ function ContainerGroupDetails({ instanceGroup }) {
|
||||
onClose={dismissError}
|
||||
title={t`Error`}
|
||||
variant="error"
|
||||
/>
|
||||
>
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { InstanceGroupsAPI } from '../../api';
|
||||
import { InstanceGroupsAPI, SettingsAPI } from '../../api';
|
||||
import RoutedTabs from '../../components/RoutedTabs';
|
||||
import ContentError from '../../components/ContentError';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
@ -31,12 +31,24 @@ function InstanceGroup({ setBreadcrumb }) {
|
||||
isLoading,
|
||||
error: contentError,
|
||||
request: fetchInstanceGroups,
|
||||
result: instanceGroup,
|
||||
result: { instanceGroup, defaultControlPlane },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InstanceGroupsAPI.readDetail(id);
|
||||
return data;
|
||||
}, [id])
|
||||
const [
|
||||
{ data },
|
||||
{
|
||||
data: { DEFAULT_CONTROL_PLANE_QUEUE_NAME },
|
||||
},
|
||||
] = await Promise.all([
|
||||
InstanceGroupsAPI.readDetail(id),
|
||||
SettingsAPI.readAll(),
|
||||
]);
|
||||
return {
|
||||
instanceGroup: data,
|
||||
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
|
||||
};
|
||||
}, [id]),
|
||||
{ instanceGroup: null, defaultControlPlane: '' }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -115,10 +127,16 @@ function InstanceGroup({ setBreadcrumb }) {
|
||||
{instanceGroup && (
|
||||
<>
|
||||
<Route path="/instance_groups/:id/edit">
|
||||
<InstanceGroupEdit instanceGroup={instanceGroup} />
|
||||
<InstanceGroupEdit
|
||||
instanceGroup={instanceGroup}
|
||||
defaultControlPlane={defaultControlPlane}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/:id/details">
|
||||
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
||||
<InstanceGroupDetails
|
||||
defaultControlPlane={defaultControlPlane}
|
||||
instanceGroup={instanceGroup}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/instance_groups/:id/instances">
|
||||
<InstanceList />
|
||||
|
||||
@ -7,6 +7,7 @@ import { Button } from '@patternfly/react-core';
|
||||
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import DeleteButton from '../../../components/DeleteButton';
|
||||
import {
|
||||
Detail,
|
||||
@ -22,7 +23,7 @@ const Unavailable = styled.span`
|
||||
color: var(--pf-global--danger-color--200);
|
||||
`;
|
||||
|
||||
function InstanceGroupDetails({ instanceGroup }) {
|
||||
function InstanceGroupDetails({ instanceGroup, defaultControlPlane }) {
|
||||
const { id, name } = instanceGroup;
|
||||
|
||||
const history = useHistory();
|
||||
@ -110,7 +111,7 @@ function InstanceGroupDetails({ instanceGroup }) {
|
||||
{t`Edit`}
|
||||
</Button>
|
||||
)}
|
||||
{name !== 'tower' &&
|
||||
{name !== defaultControlPlane &&
|
||||
instanceGroup.summary_fields.user_capabilities &&
|
||||
instanceGroup.summary_fields.user_capabilities.delete && (
|
||||
<DeleteButton
|
||||
@ -132,7 +133,9 @@ function InstanceGroupDetails({ instanceGroup }) {
|
||||
onClose={dismissError}
|
||||
title={t`Error`}
|
||||
variant="error"
|
||||
/>
|
||||
>
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
|
||||
@ -5,7 +5,7 @@ import { CardBody } from '../../../components/Card';
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
import InstanceGroupForm from '../shared/InstanceGroupForm';
|
||||
|
||||
function InstanceGroupEdit({ instanceGroup }) {
|
||||
function InstanceGroupEdit({ instanceGroup, defaultControlPlane }) {
|
||||
const history = useHistory();
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const detailsUrl = `/instance_groups/${instanceGroup.id}/details`;
|
||||
@ -27,6 +27,7 @@ function InstanceGroupEdit({ instanceGroup }) {
|
||||
<CardBody>
|
||||
<InstanceGroupForm
|
||||
instanceGroup={instanceGroup}
|
||||
defaultControlPlane={defaultControlPlane}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
onCancel={handleCancel}
|
||||
|
||||
@ -55,7 +55,10 @@ describe('<InstanceGroupEdit>', () => {
|
||||
history = createMemoryHistory();
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<InstanceGroupEdit instanceGroup={instanceGroupData} />,
|
||||
<InstanceGroupEdit
|
||||
defaultControlPlane="controlplane"
|
||||
instanceGroup={instanceGroupData}
|
||||
/>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
@ -68,12 +71,13 @@ describe('<InstanceGroupEdit>', () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('tower instance group name can not be updated', async () => {
|
||||
test('controlplane instance group name can not be updated', async () => {
|
||||
let towerWrapper;
|
||||
await act(async () => {
|
||||
towerWrapper = mountWithContexts(
|
||||
<InstanceGroupEdit
|
||||
instanceGroup={{ ...instanceGroupData, name: 'tower' }}
|
||||
defaultControlPlane="controlplane"
|
||||
instanceGroup={{ ...instanceGroupData, name: 'controlplane' }}
|
||||
/>,
|
||||
{
|
||||
context: { router: { history } },
|
||||
@ -85,7 +89,7 @@ describe('<InstanceGroupEdit>', () => {
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
towerWrapper.find('input#instance-group-name').prop('value')
|
||||
).toEqual('tower');
|
||||
).toEqual('controlplane');
|
||||
});
|
||||
|
||||
test('handleSubmit should call the api and redirect to details page', async () => {
|
||||
|
||||
@ -4,13 +4,14 @@ import { useLocation, useRouteMatch, Link } from 'react-router-dom';
|
||||
import { t, Plural } from '@lingui/macro';
|
||||
import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
|
||||
|
||||
import { InstanceGroupsAPI } from '../../../api';
|
||||
import { InstanceGroupsAPI, SettingsAPI } from '../../../api';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import useSelected from '../../../util/useSelected';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
@ -25,7 +26,11 @@ const QS_CONFIG = getQSConfig('instance-group', {
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
function modifyInstanceGroups(items = []) {
|
||||
function modifyInstanceGroups(
|
||||
items = [],
|
||||
defaultControlPlane,
|
||||
defaultExecution
|
||||
) {
|
||||
return items.map(item => {
|
||||
const clonedItem = {
|
||||
...item,
|
||||
@ -36,16 +41,44 @@ function modifyInstanceGroups(items = []) {
|
||||
},
|
||||
},
|
||||
};
|
||||
if (clonedItem.name === 'tower') {
|
||||
if (clonedItem.name === (defaultControlPlane || defaultExecution)) {
|
||||
clonedItem.summary_fields.user_capabilities.delete = false;
|
||||
}
|
||||
return clonedItem;
|
||||
});
|
||||
}
|
||||
|
||||
function InstanceGroupList() {
|
||||
function InstanceGroupList({
|
||||
isKubernetes,
|
||||
isSettingsRequestLoading,
|
||||
settingsRequestError,
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const {
|
||||
error: protectedItemsError,
|
||||
isloading: isLoadingProtectedItems,
|
||||
request: fetchProtectedItems,
|
||||
result: { defaultControlPlane, defaultExecution },
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: {
|
||||
DEFAULT_CONTROL_PLANE_QUEUE_NAME,
|
||||
DEFAULT_EXECUTION_QUEUE_NAME,
|
||||
},
|
||||
} = await SettingsAPI.readAll();
|
||||
return {
|
||||
defaultControlPlane: DEFAULT_CONTROL_PLANE_QUEUE_NAME,
|
||||
defaultExecution: DEFAULT_EXECUTION_QUEUE_NAME,
|
||||
};
|
||||
}, []),
|
||||
{ defaultControlPlane: '', defaultExecution: '' }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProtectedItems();
|
||||
}, [fetchProtectedItems]);
|
||||
|
||||
const {
|
||||
error: contentError,
|
||||
@ -100,7 +133,11 @@ function InstanceGroupList() {
|
||||
selectAll,
|
||||
} = useSelected(instanceGroups);
|
||||
|
||||
const modifiedSelected = modifyInstanceGroups(selected);
|
||||
const modifiedSelected = modifyInstanceGroups(
|
||||
selected,
|
||||
defaultControlPlane,
|
||||
defaultExecution
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: deleteLoading,
|
||||
@ -128,63 +165,66 @@ function InstanceGroupList() {
|
||||
const canAdd = actions && actions.POST;
|
||||
|
||||
function cannotDelete(item) {
|
||||
return !item.summary_fields.user_capabilities.delete;
|
||||
return (
|
||||
!item.summary_fields.user_capabilities.delete ||
|
||||
item.name === defaultExecution ||
|
||||
item.name === defaultControlPlane
|
||||
);
|
||||
}
|
||||
|
||||
const pluralizedItemName = t`Instance Groups`;
|
||||
|
||||
let errorMessageDelete = '';
|
||||
const notdeletedable = selected.filter(
|
||||
i => i.name === defaultControlPlane || i.name === defaultExecution
|
||||
);
|
||||
|
||||
if (modifiedSelected.some(item => item.name === 'tower')) {
|
||||
const itemsUnableToDelete = modifiedSelected
|
||||
.filter(cannotDelete)
|
||||
.filter(item => item.name !== 'tower')
|
||||
.map(item => item.name)
|
||||
.join(', ');
|
||||
|
||||
if (itemsUnableToDelete) {
|
||||
if (modifiedSelected.some(cannotDelete)) {
|
||||
errorMessageDelete = t`You do not have permission to delete ${pluralizedItemName}: ${itemsUnableToDelete}. `;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessageDelete.length > 0) {
|
||||
errorMessageDelete = errorMessageDelete.concat('\n');
|
||||
}
|
||||
errorMessageDelete = errorMessageDelete.concat(
|
||||
t`The tower instance group cannot be deleted.`
|
||||
if (notdeletedable.length) {
|
||||
errorMessageDelete = (
|
||||
<Plural
|
||||
value={notdeletedable.length}
|
||||
one="The following Instance Group cannot be deleted"
|
||||
other="The following Instance Groups cannot be deleted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const addContainerGroup = t`Add container group`;
|
||||
const addInstanceGroup = t`Add instance group`;
|
||||
|
||||
const addButton = (
|
||||
<AddDropDownButton
|
||||
ouiaId="add-instance-group-button"
|
||||
key="add"
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
ouiaId="add-container-group-item"
|
||||
to="/instance_groups/container_group/add"
|
||||
component={Link}
|
||||
key={addContainerGroup}
|
||||
aria-label={addContainerGroup}
|
||||
>
|
||||
{addContainerGroup}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
ouiaId="add-instance-group-item"
|
||||
to="/instance_groups/add"
|
||||
component={Link}
|
||||
key={addInstanceGroup}
|
||||
aria-label={addInstanceGroup}
|
||||
>
|
||||
{addInstanceGroup}
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const addButton =
|
||||
!isSettingsRequestLoading && !isKubernetes ? (
|
||||
<AddDropDownButton
|
||||
ouiaId="add-instance-group-button"
|
||||
key="add"
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
ouiaId="add-container-group-item"
|
||||
to="/instance_groups/container_group/add"
|
||||
component={Link}
|
||||
key={addContainerGroup}
|
||||
aria-label={addContainerGroup}
|
||||
>
|
||||
{addContainerGroup}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
ouiaId="add-instance-group-item"
|
||||
to="/instance_groups/add"
|
||||
component={Link}
|
||||
key={addInstanceGroup}
|
||||
aria-label={addInstanceGroup}
|
||||
>
|
||||
{addInstanceGroup}
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<ToolbarAddButton
|
||||
key="add"
|
||||
ouiaId="add-container-group-button"
|
||||
linkTo={`${match.url}/container_group/add`}
|
||||
/>
|
||||
);
|
||||
|
||||
const getDetailUrl = item => {
|
||||
return item.is_container_group
|
||||
@ -199,8 +239,15 @@ function InstanceGroupList() {
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || deleteLoading}
|
||||
contentError={
|
||||
contentError || settingsRequestError || protectedItemsError
|
||||
}
|
||||
hasContentLoading={
|
||||
isLoading ||
|
||||
deleteLoading ||
|
||||
isSettingsRequestLoading ||
|
||||
isLoadingProtectedItems
|
||||
}
|
||||
items={instanceGroups}
|
||||
itemCount={instanceGroupsCount}
|
||||
pluralizedItemName={pluralizedItemName}
|
||||
@ -220,6 +267,7 @@ function InstanceGroupList() {
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleDelete}
|
||||
cannotDelete={cannotDelete}
|
||||
itemsToDelete={modifiedSelected}
|
||||
pluralizedItemName={t`Instance Groups`}
|
||||
errorMessage={errorMessageDelete}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
OrganizationsAPI,
|
||||
InventoriesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
SettingsAPI,
|
||||
} from '../../../api';
|
||||
import InstanceGroupList from './InstanceGroupList';
|
||||
|
||||
@ -18,6 +19,7 @@ jest.mock('../../../api/models/InstanceGroups');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/UnifiedJobTemplates');
|
||||
jest.mock('../../../api/models/Settings');
|
||||
|
||||
const instanceGroups = {
|
||||
data: {
|
||||
@ -32,7 +34,7 @@ const instanceGroups = {
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'tower',
|
||||
name: 'controlplan',
|
||||
type: 'instance_group',
|
||||
url: '/api/v2/instance_groups/2',
|
||||
consumed_capacity: 42,
|
||||
@ -40,6 +42,14 @@ const instanceGroups = {
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'default',
|
||||
type: 'instance_group',
|
||||
url: '/api/v2/instance_groups/2',
|
||||
consumed_capacity: 42,
|
||||
summary_fields: { user_capabilities: { edit: true, delete: true } },
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Bar',
|
||||
type: 'instance_group',
|
||||
url: '/api/v2/instance_groups/3',
|
||||
@ -47,11 +57,17 @@ const instanceGroups = {
|
||||
summary_fields: { user_capabilities: { edit: true, delete: false } },
|
||||
},
|
||||
],
|
||||
count: 3,
|
||||
count: 4,
|
||||
},
|
||||
};
|
||||
|
||||
const options = { data: { actions: { POST: true } } };
|
||||
const settings = {
|
||||
data: {
|
||||
DEFAULT_CONTROL_PLANE_QUEUE_NAME: 'controlplan',
|
||||
DEFAULT_EXECUTION_QUEUE_NAME: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
describe('<InstanceGroupList />', () => {
|
||||
let wrapper;
|
||||
@ -62,6 +78,7 @@ describe('<InstanceGroupList />', () => {
|
||||
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||
InstanceGroupsAPI.read.mockResolvedValue(instanceGroups);
|
||||
InstanceGroupsAPI.readOptions.mockResolvedValue(options);
|
||||
SettingsAPI.readAll.mockResolvedValue(settings);
|
||||
});
|
||||
|
||||
test('should have data fetched and render 3 rows', async () => {
|
||||
@ -69,7 +86,7 @@ describe('<InstanceGroupList />', () => {
|
||||
wrapper = mountWithContexts(<InstanceGroupList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||
expect(wrapper.find('InstanceGroupListItem').length).toBe(3);
|
||||
expect(wrapper.find('InstanceGroupListItem').length).toBe(4);
|
||||
expect(InstanceGroupsAPI.read).toBeCalled();
|
||||
expect(InstanceGroupsAPI.readOptions).toBeCalled();
|
||||
});
|
||||
@ -109,13 +126,13 @@ describe('<InstanceGroupList />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should not be able to delete tower instance group', async () => {
|
||||
test('should not be able to delete controlplan or default instance group', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<InstanceGroupList />);
|
||||
});
|
||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||
|
||||
const instanceGroupIndex = [0, 1, 2];
|
||||
const instanceGroupIndex = [0, 1, 2, 3];
|
||||
|
||||
instanceGroupIndex.forEach(element => {
|
||||
wrapper
|
||||
|
||||
@ -1,17 +1,37 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { SettingsAPI } from '../../api';
|
||||
|
||||
import InstanceGroupAdd from './InstanceGroupAdd';
|
||||
import InstanceGroupList from './InstanceGroupList';
|
||||
import InstanceGroup from './InstanceGroup';
|
||||
|
||||
import ContainerGroupAdd from './ContainerGroupAdd';
|
||||
import ContainerGroup from './ContainerGroup';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
|
||||
function InstanceGroups() {
|
||||
const {
|
||||
request: settingsRequest,
|
||||
isLoading: isSettingsRequestLoading,
|
||||
error: settingsRequestError,
|
||||
result: isKubernetes,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: { IS_K8S },
|
||||
} = await SettingsAPI.readCategory('all');
|
||||
return IS_K8S;
|
||||
}, []),
|
||||
{ isLoading: true }
|
||||
);
|
||||
useEffect(() => {
|
||||
settingsRequest();
|
||||
}, [settingsRequest]);
|
||||
|
||||
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||
'/instance_groups': t`Instance Groups`,
|
||||
'/instance_groups/add': t`Create new instance group`,
|
||||
@ -39,6 +59,7 @@ function InstanceGroups() {
|
||||
[`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScreenHeader
|
||||
@ -52,14 +73,20 @@ function InstanceGroups() {
|
||||
<Route path="/instance_groups/container_group/:id">
|
||||
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/instance_groups/add">
|
||||
<InstanceGroupAdd />
|
||||
</Route>
|
||||
{!isSettingsRequestLoading && !isKubernetes ? (
|
||||
<Route path="/instance_groups/add">
|
||||
<InstanceGroupAdd />
|
||||
</Route>
|
||||
) : null}
|
||||
<Route path="/instance_groups/:id">
|
||||
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
|
||||
</Route>
|
||||
<Route path="/instance_groups">
|
||||
<InstanceGroupList />
|
||||
<InstanceGroupList
|
||||
isKubernetes={isKubernetes}
|
||||
isSettingsRequestLoading={isSettingsRequestLoading}
|
||||
settingsRequestError={settingsRequestError}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
|
||||
@ -27,6 +27,8 @@ function ContainerGroupFormFields({ instanceGroup }) {
|
||||
'credential'
|
||||
);
|
||||
|
||||
const [nameField] = useField('name');
|
||||
|
||||
const [overrideField] = useField('override');
|
||||
|
||||
const handleCredentialUpdate = useCallback(
|
||||
@ -45,6 +47,7 @@ function ContainerGroupFormFields({ instanceGroup }) {
|
||||
label={t`Name`}
|
||||
type="text"
|
||||
validate={required(null)}
|
||||
isDisabled={nameField.value === 'default'}
|
||||
isRequired
|
||||
/>
|
||||
<CredentialLookup
|
||||
|
||||
@ -10,8 +10,9 @@ import FormActionGroup from '../../../components/FormActionGroup';
|
||||
import { required, minMaxValue } from '../../../util/validators';
|
||||
import { FormColumnLayout } from '../../../components/FormLayout';
|
||||
|
||||
function InstanceGroupFormFields() {
|
||||
function InstanceGroupFormFields({ defaultControlPlane }) {
|
||||
const [instanceGroupNameField, ,] = useField('name');
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
@ -21,7 +22,7 @@ function InstanceGroupFormFields() {
|
||||
type="text"
|
||||
validate={required(null)}
|
||||
isRequired
|
||||
isDisabled={instanceGroupNameField.value === 'tower'}
|
||||
isDisabled={instanceGroupNameField.value === defaultControlPlane}
|
||||
/>
|
||||
<FormField
|
||||
id="instance-group-policy-instance-minimum"
|
||||
@ -50,6 +51,7 @@ function InstanceGroupFormFields() {
|
||||
|
||||
function InstanceGroupForm({
|
||||
instanceGroup = {},
|
||||
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitError,
|
||||
|
||||
@ -23,12 +23,12 @@ function InventoryAdd() {
|
||||
organization: organization.id,
|
||||
...remainingValues,
|
||||
});
|
||||
if (instanceGroups) {
|
||||
const associatePromises = instanceGroups.map(async ig =>
|
||||
InventoriesAPI.associateInstanceGroup(inventoryId, ig.id)
|
||||
);
|
||||
await Promise.all(associatePromises);
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||
for (const group of instanceGroups) {
|
||||
await InventoriesAPI.associateInstanceGroup(inventoryId, group.id);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
const url = history.location.pathname.startsWith(
|
||||
'/inventories/smart_inventory'
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ import { CardBody } from '../../../components/Card';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import InventoryForm from '../shared/InventoryForm';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
import useIsMounted from '../../../util/useIsMounted';
|
||||
|
||||
function InventoryEdit({ inventory }) {
|
||||
@ -54,20 +53,12 @@ function InventoryEdit({ inventory }) {
|
||||
organization: organization.id,
|
||||
...remainingValues,
|
||||
});
|
||||
if (instanceGroups) {
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
associatedInstanceGroups,
|
||||
instanceGroups
|
||||
);
|
||||
await InventoriesAPI.orderInstanceGroups(
|
||||
inventory.id,
|
||||
instanceGroups,
|
||||
associatedInstanceGroups
|
||||
);
|
||||
|
||||
const associatePromises = added.map(async ig =>
|
||||
InventoriesAPI.associateInstanceGroup(inventory.id, ig.id)
|
||||
);
|
||||
const disassociatePromises = removed.map(async ig =>
|
||||
InventoriesAPI.disassociateInstanceGroup(inventory.id, ig.id)
|
||||
);
|
||||
await Promise.all([...associatePromises, ...disassociatePromises]);
|
||||
}
|
||||
const url =
|
||||
history.location.pathname.search('smart') > -1
|
||||
? `/inventories/smart_inventory/${inventory.id}/details`
|
||||
|
||||
@ -106,17 +106,10 @@ describe('<InventoryEdit />', () => {
|
||||
});
|
||||
});
|
||||
await sleep(0);
|
||||
instanceGroups.map(IG =>
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
IG.id
|
||||
)
|
||||
);
|
||||
associatedInstanceGroups.map(async aIG =>
|
||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
aIG.id
|
||||
)
|
||||
expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledWith(
|
||||
mockInventory.id,
|
||||
instanceGroups,
|
||||
associatedInstanceGroups
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -168,6 +168,18 @@ function InventoryList() {
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: t`Inventory Type`,
|
||||
key: 'or__kind',
|
||||
options: [
|
||||
['', t`Inventory`],
|
||||
['smart', t`Smart Inventory`],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Organization`,
|
||||
key: 'organization__name',
|
||||
},
|
||||
{
|
||||
name: t`Description`,
|
||||
key: 'description__icontains',
|
||||
|
||||
@ -19,11 +19,12 @@ function SmartInventoryAdd() {
|
||||
data: { id: invId },
|
||||
} = await InventoriesAPI.create(values);
|
||||
|
||||
await Promise.all(
|
||||
groupsToAssociate.map(({ id }) =>
|
||||
InventoriesAPI.associateInstanceGroup(invId, id)
|
||||
)
|
||||
);
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||
for (const group of groupsToAssociate) {
|
||||
await InventoriesAPI.associateInstanceGroup(invId, group.id);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
return invId;
|
||||
}, [])
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Inventory } from '../../../types';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
@ -17,7 +16,7 @@ function SmartInventoryEdit({ inventory }) {
|
||||
error: contentError,
|
||||
isLoading: hasContentLoading,
|
||||
request: fetchInstanceGroups,
|
||||
result: instanceGroups,
|
||||
result: initialInstanceGroups,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
@ -40,15 +39,10 @@ function SmartInventoryEdit({ inventory }) {
|
||||
useCallback(
|
||||
async (values, groupsToAssociate, groupsToDisassociate) => {
|
||||
const { data } = await InventoriesAPI.update(inventory.id, values);
|
||||
await Promise.all(
|
||||
groupsToAssociate.map(id =>
|
||||
InventoriesAPI.associateInstanceGroup(inventory.id, id)
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
groupsToDisassociate.map(id =>
|
||||
InventoriesAPI.disassociateInstanceGroup(inventory.id, id)
|
||||
)
|
||||
await InventoriesAPI.orderInstanceGroups(
|
||||
inventory.id,
|
||||
groupsToAssociate,
|
||||
groupsToDisassociate
|
||||
);
|
||||
return data;
|
||||
},
|
||||
@ -68,20 +62,13 @@ function SmartInventoryEdit({ inventory }) {
|
||||
const handleSubmit = async form => {
|
||||
const { instance_groups, organization, ...remainingForm } = form;
|
||||
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
instanceGroups,
|
||||
instance_groups
|
||||
);
|
||||
const addedIds = added.map(({ id }) => id);
|
||||
const removedIds = removed.map(({ id }) => id);
|
||||
|
||||
await submitRequest(
|
||||
{
|
||||
organization: organization?.id,
|
||||
...remainingForm,
|
||||
},
|
||||
addedIds,
|
||||
removedIds
|
||||
instance_groups,
|
||||
initialInstanceGroups
|
||||
);
|
||||
};
|
||||
|
||||
@ -104,7 +91,7 @@ function SmartInventoryEdit({ inventory }) {
|
||||
<CardBody>
|
||||
<SmartInventoryForm
|
||||
inventory={inventory}
|
||||
instanceGroups={instanceGroups}
|
||||
instanceGroups={initialInstanceGroups}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
|
||||
@ -104,8 +104,7 @@ describe('<SmartInventoryEdit />', () => {
|
||||
});
|
||||
});
|
||||
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('successful form submission should trigger redirect to details', async () => {
|
||||
|
||||
@ -67,6 +67,7 @@ function JobDetail({ job }) {
|
||||
workflow_job_template: workflowJobTemplate,
|
||||
labels,
|
||||
project,
|
||||
project_update: projectUpdate,
|
||||
source_workflow_job,
|
||||
execution_environment: executionEnvironment,
|
||||
} = job.summary_fields;
|
||||
@ -104,6 +105,25 @@ function JobDetail({ job }) {
|
||||
);
|
||||
};
|
||||
|
||||
const buildProjectDetailValue = () => {
|
||||
if (projectUpdate) {
|
||||
return (
|
||||
<StatusDetailValue>
|
||||
<Link to={`/jobs/project/${projectUpdate.id}`}>
|
||||
<StatusIcon status={project.status} />
|
||||
</Link>
|
||||
<Link to={`/projects/${project.id}`}>{project.name}</Link>
|
||||
</StatusDetailValue>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StatusDetailValue>
|
||||
<StatusIcon status={project.status} />
|
||||
<Link to={`/projects/${project.id}`}>{project.name}</Link>
|
||||
</StatusDetailValue>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
@ -199,15 +219,7 @@ function JobDetail({ job }) {
|
||||
/>
|
||||
)}
|
||||
{project && (
|
||||
<Detail
|
||||
label={t`Project`}
|
||||
value={
|
||||
<StatusDetailValue>
|
||||
{project.status && <StatusIcon status={project.status} />}
|
||||
<Link to={`/projects/${project.id}`}>{project.name}</Link>
|
||||
</StatusDetailValue>
|
||||
}
|
||||
/>
|
||||
<Detail label={t`Project`} value={buildProjectDetailValue()} />
|
||||
)}
|
||||
{scmBranch && (
|
||||
<Detail
|
||||
|
||||
@ -434,28 +434,42 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||
);
|
||||
}
|
||||
|
||||
const eventPromise = getJobModel(job.type).readEvents(job.id, {
|
||||
...params,
|
||||
...parseQueryString(QS_CONFIG, location.search),
|
||||
});
|
||||
|
||||
let countRequest;
|
||||
if (isJobRunning(job?.status)) {
|
||||
// If the job is running, it means we're using limit-offset pagination. Requests
|
||||
// with limit-offset pagination won't return a total event count for performance
|
||||
// reasons. In this situation, we derive the remote row count by using the highest
|
||||
// counter available in the database.
|
||||
countRequest = async () => {
|
||||
const {
|
||||
data: { results: lastEvents = [] },
|
||||
} = await getJobModel(job.type).readEvents(job.id, {
|
||||
order_by: '-counter',
|
||||
limit: 1,
|
||||
});
|
||||
return lastEvents.length >= 1 ? lastEvents[0].counter : 0;
|
||||
};
|
||||
} else {
|
||||
countRequest = async () => {
|
||||
const {
|
||||
data: { count: eventCount },
|
||||
} = await eventPromise;
|
||||
return eventCount;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [
|
||||
{
|
||||
data: { results: fetchedEvents = [] },
|
||||
},
|
||||
{
|
||||
data: { results: lastEvents = [] },
|
||||
},
|
||||
] = await Promise.all([
|
||||
getJobModel(job.type).readEvents(job.id, {
|
||||
...params,
|
||||
...parseQueryString(QS_CONFIG, location.search),
|
||||
}),
|
||||
getJobModel(job.type).readEvents(job.id, {
|
||||
order_by: '-counter',
|
||||
limit: 1,
|
||||
}),
|
||||
]);
|
||||
let count = 0;
|
||||
if (lastEvents.length >= 1 && lastEvents[0]?.counter) {
|
||||
count = lastEvents[0]?.counter;
|
||||
}
|
||||
count,
|
||||
] = await Promise.all([eventPromise, countRequest()]);
|
||||
|
||||
if (isMounted.current) {
|
||||
let countOffset = 0;
|
||||
|
||||
@ -13,6 +13,22 @@ import mockFilteredJobEventsData from './data.filtered_job_events.json';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const applyJobEventMock = mockJobEvents => {
|
||||
const mockReadEvents = async (jobId, params) => {
|
||||
const [...results] = mockJobEvents.results;
|
||||
if (params.order_by && params.order_by.includes('-')) {
|
||||
results.reverse();
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
results,
|
||||
count: mockJobEvents.count,
|
||||
},
|
||||
};
|
||||
};
|
||||
JobsAPI.readEvents = jest.fn().mockImplementation(mockReadEvents);
|
||||
};
|
||||
|
||||
const generateChattyRows = () => {
|
||||
const rows = [
|
||||
'',
|
||||
@ -82,24 +98,13 @@ const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
||||
describe('<JobOutput />', () => {
|
||||
let wrapper;
|
||||
const mockJob = mockJobData;
|
||||
const mockJobEvents = mockJobEventsData;
|
||||
|
||||
beforeEach(() => {
|
||||
JobsAPI.readEvents = (jobId, params) => {
|
||||
const [...results] = mockJobEvents.results;
|
||||
if (params.order_by && params.order_by.includes('-')) {
|
||||
results.reverse();
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
results,
|
||||
},
|
||||
};
|
||||
};
|
||||
applyJobEventMock(mockJobEventsData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
@ -141,7 +146,7 @@ describe('<JobOutput />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe(
|
||||
expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
|
||||
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
|
||||
);
|
||||
await act(async () => {
|
||||
@ -149,10 +154,10 @@ describe('<JobOutput />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=76) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 76"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
expect(jobEvents.at(2).prop('stdout')).toBe(
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=77) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 77"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
await act(async () => {
|
||||
@ -169,7 +174,7 @@ describe('<JobOutput />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(jobEvents.length - 1).prop('stdout')).toBe(
|
||||
expect(jobEvents.at(jobEvents.length - 2).prop('stdout')).toBe(
|
||||
'\r\nPLAY RECAP *********************************************************************\r\n\u001b[0;32mlocalhost\u001b[0m : \u001b[0;32mok=1 \u001b[0m changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \r\n'
|
||||
);
|
||||
Object.defineProperty(
|
||||
@ -266,11 +271,7 @@ describe('<JobOutput />', () => {
|
||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||
});
|
||||
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
||||
JobsAPI.readEvents = jest.fn();
|
||||
JobsAPI.readEvents.mockClear();
|
||||
JobsAPI.readEvents.mockResolvedValueOnce({
|
||||
data: mockFilteredJobEventsData,
|
||||
});
|
||||
applyJobEventMock(mockFilteredJobEventsData);
|
||||
await act(async () => {
|
||||
wrapper.find(searchTextInput).instance().value = '99';
|
||||
wrapper.find(searchTextInput).simulate('change');
|
||||
@ -281,14 +282,13 @@ describe('<JobOutput />', () => {
|
||||
});
|
||||
wrapper.update();
|
||||
expect(JobsAPI.readEvents).toHaveBeenCalled();
|
||||
// TODO: Fix these assertions
|
||||
// const jobEvents = wrapper.find('JobEvent');
|
||||
// expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||
// '\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
// );
|
||||
// expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
// '\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
// );
|
||||
const jobEvents = wrapper.find('JobEvent');
|
||||
expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=99) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 99"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
expect(jobEvents.at(1).prop('stdout')).toBe(
|
||||
'\u001b[0;32mok: [localhost] => (item=199) => {\u001b[0m\r\n\u001b[0;32m "msg": "This is a debug message: 199"\u001b[0m\r\n\u001b[0;32m}\u001b[0m'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error', async () => {
|
||||
|
||||
@ -4,7 +4,6 @@ import React from 'react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button as PFButton } from '@patternfly/react-core';
|
||||
import {
|
||||
PlusIcon,
|
||||
AngleDoubleUpIcon,
|
||||
AngleDoubleDownIcon,
|
||||
AngleUpIcon,
|
||||
@ -17,6 +16,7 @@ const Wrapper = styled.div`
|
||||
height: 35px;
|
||||
outline: 1px solid #d7d7d7;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Button = styled(PFButton)`
|
||||
@ -31,14 +31,6 @@ const PageControls = ({
|
||||
onScrollPrevious,
|
||||
}) => (
|
||||
<Wrapper>
|
||||
<Button
|
||||
ouiaId="job-output-expand-collapse-lines-button"
|
||||
aria-label={t`Toggle expand/collapse event lines`}
|
||||
variant="plain"
|
||||
css="margin-right: auto"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button
|
||||
ouiaId="job-output-scroll-previous-button"
|
||||
aria-label={t`Scroll previous`}
|
||||
|
||||
@ -3,14 +3,12 @@ import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import PageControls from './PageControls';
|
||||
|
||||
let wrapper;
|
||||
let PlusIcon;
|
||||
let AngleDoubleUpIcon;
|
||||
let AngleDoubleDownIcon;
|
||||
let AngleUpIcon;
|
||||
let AngleDownIcon;
|
||||
|
||||
const findChildren = () => {
|
||||
PlusIcon = wrapper.find('PlusIcon');
|
||||
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
|
||||
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
|
||||
AngleUpIcon = wrapper.find('AngleUpIcon');
|
||||
@ -26,7 +24,6 @@ describe('PageControls', () => {
|
||||
test('should render menu control icons', () => {
|
||||
wrapper = mountWithContexts(<PageControls />);
|
||||
findChildren();
|
||||
expect(PlusIcon.length).toBe(1);
|
||||
expect(AngleDoubleUpIcon.length).toBe(1);
|
||||
expect(AngleDoubleDownIcon.length).toBe(1);
|
||||
expect(AngleUpIcon.length).toBe(1);
|
||||
|
||||
@ -11,10 +11,7 @@ import { secondsToHHMMSS } from '../../../util/dates';
|
||||
import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils';
|
||||
|
||||
const NodeG = styled.g`
|
||||
cursor: ${props =>
|
||||
props.job && props.job.type !== 'workflow_approval'
|
||||
? 'pointer'
|
||||
: 'default'};
|
||||
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||
`;
|
||||
|
||||
const JobTopLine = styled.div`
|
||||
@ -90,8 +87,10 @@ function WorkflowOutputNode({ mouseEnter, mouseLeave, node }) {
|
||||
}
|
||||
|
||||
const handleNodeClick = () => {
|
||||
if (job && job.type !== 'workflow_aproval') {
|
||||
history.push(`/jobs/${job.id}/details`);
|
||||
if (job) {
|
||||
const basePath =
|
||||
job.type !== 'workflow_approval' ? 'jobs' : 'workflow_approvals';
|
||||
history.push(`/${basePath}/${job.id}/details`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -41,15 +41,19 @@ function OrganizationAdd() {
|
||||
...values,
|
||||
default_environment: values.default_environment?.id,
|
||||
});
|
||||
await Promise.all(
|
||||
groupsToAssociate
|
||||
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id))
|
||||
.concat(
|
||||
values.galaxy_credentials.map(({ id: credId }) =>
|
||||
OrganizationsAPI.associateGalaxyCredential(response.id, credId)
|
||||
)
|
||||
)
|
||||
);
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||
for (const group of groupsToAssociate) {
|
||||
await OrganizationsAPI.associateInstanceGroup(response.id, group.id);
|
||||
}
|
||||
for (const credential of values.galaxy_credentials) {
|
||||
await OrganizationsAPI.associateGalaxyCredential(
|
||||
response.id,
|
||||
credential.id
|
||||
);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
|
||||
history.push(`/organizations/${response.id}`);
|
||||
} catch (error) {
|
||||
setFormError(error);
|
||||
|
||||
@ -95,6 +95,12 @@ describe('<OrganizationAdd />', () => {
|
||||
description: 'new description',
|
||||
galaxy_credentials: [],
|
||||
};
|
||||
const mockInstanceGroups = [
|
||||
{
|
||||
name: 'mock ig',
|
||||
id: 3,
|
||||
},
|
||||
];
|
||||
OrganizationsAPI.create.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 5,
|
||||
@ -109,7 +115,10 @@ describe('<OrganizationAdd />', () => {
|
||||
wrapper = mountWithContexts(<OrganizationAdd />);
|
||||
});
|
||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
||||
await wrapper.find('OrganizationForm').prop('onSubmit')(orgData, [3]);
|
||||
await wrapper.find('OrganizationForm').prop('onSubmit')(
|
||||
orgData,
|
||||
mockInstanceGroups
|
||||
);
|
||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
|
||||
});
|
||||
|
||||
|
||||
@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import { OrganizationsAPI } from '../../../api';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
import OrganizationForm from '../shared/OrganizationForm';
|
||||
|
||||
const isEqual = (array1, array2) =>
|
||||
array1.length === array2.length &&
|
||||
array1.every((element, index) => {
|
||||
return element.id === array2[index].id;
|
||||
});
|
||||
|
||||
function OrganizationEdit({ organization }) {
|
||||
const detailsUrl = `/organizations/${organization.id}/details`;
|
||||
const history = useHistory();
|
||||
@ -17,43 +22,35 @@ function OrganizationEdit({ organization }) {
|
||||
groupsToDisassociate
|
||||
) => {
|
||||
try {
|
||||
const {
|
||||
added: addedCredentials,
|
||||
removed: removedCredentials,
|
||||
} = getAddedAndRemoved(
|
||||
organization.galaxy_credentials,
|
||||
values.galaxy_credentials
|
||||
);
|
||||
|
||||
const addedCredentialIds = addedCredentials.map(({ id }) => id);
|
||||
const removedCredentialIds = removedCredentials.map(({ id }) => id);
|
||||
|
||||
await OrganizationsAPI.update(organization.id, {
|
||||
...values,
|
||||
default_environment: values.default_environment?.id || null,
|
||||
});
|
||||
await Promise.all(
|
||||
groupsToAssociate
|
||||
.map(id =>
|
||||
OrganizationsAPI.associateInstanceGroup(organization.id, id)
|
||||
)
|
||||
.concat(
|
||||
addedCredentialIds.map(id =>
|
||||
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
|
||||
)
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
await OrganizationsAPI.orderInstanceGroups(
|
||||
organization.id,
|
||||
groupsToAssociate,
|
||||
groupsToDisassociate
|
||||
.map(id =>
|
||||
OrganizationsAPI.disassociateInstanceGroup(organization.id, id)
|
||||
)
|
||||
.concat(
|
||||
removedCredentialIds.map(id =>
|
||||
OrganizationsAPI.disassociateGalaxyCredential(organization.id, id)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to avoid race condition
|
||||
if (
|
||||
!isEqual(organization.galaxy_credentials, values.galaxy_credentials)
|
||||
) {
|
||||
for (const credential of organization.galaxy_credentials) {
|
||||
await OrganizationsAPI.disassociateGalaxyCredential(
|
||||
organization.id,
|
||||
credential.id
|
||||
);
|
||||
}
|
||||
for (const credential of values.galaxy_credentials) {
|
||||
await OrganizationsAPI.associateGalaxyCredential(
|
||||
organization.id,
|
||||
credential.id
|
||||
);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
history.push(detailsUrl);
|
||||
} catch (error) {
|
||||
setFormError(error);
|
||||
|
||||
@ -54,18 +54,34 @@ describe('<OrganizationEdit />', () => {
|
||||
name: 'new name',
|
||||
description: 'new description',
|
||||
};
|
||||
const newInstanceGroups = [
|
||||
{
|
||||
name: 'mock three',
|
||||
id: 3,
|
||||
},
|
||||
{
|
||||
name: 'mock four',
|
||||
id: 4,
|
||||
},
|
||||
];
|
||||
const oldInstanceGroups = [
|
||||
{
|
||||
name: 'mock two',
|
||||
id: 2,
|
||||
},
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('OrganizationForm').invoke('onSubmit')(
|
||||
updatedOrgData,
|
||||
[3, 4],
|
||||
[2]
|
||||
newInstanceGroups,
|
||||
oldInstanceGroups
|
||||
);
|
||||
});
|
||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
|
||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
|
||||
expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
2
|
||||
expect(OrganizationsAPI.orderInstanceGroups).toHaveBeenCalledWith(
|
||||
mockData.id,
|
||||
newInstanceGroups,
|
||||
oldInstanceGroups
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Formik, useField, useFormikContext } from 'formik';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
|
||||
import { OrganizationsAPI } from '../../../api';
|
||||
@ -15,7 +15,6 @@ import {
|
||||
InstanceGroupsLookup,
|
||||
ExecutionEnvironmentLookup,
|
||||
} from '../../../components/Lookup';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
import { required, minMaxValue } from '../../../util/validators';
|
||||
import { FormColumnLayout } from '../../../components/FormLayout';
|
||||
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
|
||||
@ -106,7 +105,20 @@ function OrganizationFormFields({
|
||||
onChange={handleCredentialUpdate}
|
||||
value={galaxyCredentialsField.value}
|
||||
multiple
|
||||
isSelectedDraggable
|
||||
fieldName="galaxy_credentials"
|
||||
modalDescription={
|
||||
<>
|
||||
<b>
|
||||
<Trans>Selected</Trans>
|
||||
</b>
|
||||
<br />
|
||||
<Trans>
|
||||
Note: The order of these credentials sets precedence for the sync
|
||||
and lookup of the content.
|
||||
</Trans>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@ -130,19 +142,13 @@ function OrganizationForm({
|
||||
};
|
||||
|
||||
const handleSubmit = values => {
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
initialInstanceGroups,
|
||||
instanceGroups
|
||||
);
|
||||
const addedIds = added.map(({ id }) => id);
|
||||
const removedIds = removed.map(({ id }) => id);
|
||||
if (
|
||||
typeof values.max_hosts !== 'number' ||
|
||||
values.max_hosts === 'undefined'
|
||||
) {
|
||||
values.max_hosts = 0;
|
||||
}
|
||||
onSubmit(values, addedIds, removedIds);
|
||||
onSubmit(values, instanceGroups, initialInstanceGroups);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -258,7 +258,14 @@ describe('<OrganizationForm />', () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
expect(onSubmit).toHaveBeenCalledWith(mockDataForm, [3], [2]);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
mockDataForm,
|
||||
[
|
||||
{ name: 'One', id: 1 },
|
||||
{ name: 'Three', id: 3 },
|
||||
],
|
||||
mockInstanceGroups
|
||||
);
|
||||
});
|
||||
|
||||
test('onSubmit does not get called if max_hosts value is out of range', async () => {
|
||||
@ -332,8 +339,8 @@ describe('<OrganizationForm />', () => {
|
||||
max_hosts: 0,
|
||||
default_environment: null,
|
||||
},
|
||||
[],
|
||||
[]
|
||||
mockInstanceGroups,
|
||||
mockInstanceGroups
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -63,11 +63,13 @@ function JobTemplateAdd() {
|
||||
return Promise.all([...associationPromises]);
|
||||
}
|
||||
|
||||
function submitInstanceGroups(templateId, addedGroups = []) {
|
||||
const associatePromises = addedGroups.map(group =>
|
||||
JobTemplatesAPI.associateInstanceGroup(templateId, group.id)
|
||||
);
|
||||
return Promise.all(associatePromises);
|
||||
async function submitInstanceGroups(templateId, addedGroups = []) {
|
||||
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||
for (const group of addedGroups) {
|
||||
await JobTemplatesAPI.associateInstanceGroup(templateId, group.id);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||
}
|
||||
|
||||
function submitCredentials(templateId, credentials = []) {
|
||||
|
||||
@ -109,8 +109,9 @@ describe('<JobTemplateAdd />', () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<JobTemplateAdd />);
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('input#template-description').text()).toBe(
|
||||
defaultProps.description
|
||||
);
|
||||
|
||||
@ -10,7 +10,7 @@ import JobTemplateForm from '../shared/JobTemplateForm';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
|
||||
function JobTemplateEdit({ template }) {
|
||||
function JobTemplateEdit({ template, reloadTemplate }) {
|
||||
const history = useHistory();
|
||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -18,11 +18,7 @@ function JobTemplateEdit({ template }) {
|
||||
|
||||
const detailsUrl = `/templates/${template.type}/${template.id}/details`;
|
||||
|
||||
const {
|
||||
request: fetchProject,
|
||||
error: fetchProjectError,
|
||||
isLoading: projectLoading,
|
||||
} = useRequest(
|
||||
const { request: fetchProject, error: fetchProjectError } = useRequest(
|
||||
useCallback(async () => {
|
||||
await ProjectsAPI.readDetail(template.project);
|
||||
}, [template.project])
|
||||
@ -65,9 +61,14 @@ function JobTemplateEdit({ template }) {
|
||||
await JobTemplatesAPI.update(template.id, remainingValues);
|
||||
await Promise.all([
|
||||
submitLabels(labels, template?.organization),
|
||||
submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
||||
submitCredentials(credentials),
|
||||
JobTemplatesAPI.orderInstanceGroups(
|
||||
template.id,
|
||||
instanceGroups,
|
||||
initialInstanceGroups
|
||||
),
|
||||
]);
|
||||
reloadTemplate();
|
||||
history.push(detailsUrl);
|
||||
} catch (error) {
|
||||
setFormSubmitError(error);
|
||||
@ -96,17 +97,6 @@ function JobTemplateEdit({ template }) {
|
||||
return results;
|
||||
};
|
||||
|
||||
const submitInstanceGroups = async (groups, initialGroups) => {
|
||||
const { added, removed } = getAddedAndRemoved(initialGroups, groups);
|
||||
const disassociatePromises = await removed.map(group =>
|
||||
JobTemplatesAPI.disassociateInstanceGroup(template.id, group.id)
|
||||
);
|
||||
const associatePromises = await added.map(group =>
|
||||
JobTemplatesAPI.associateInstanceGroup(template.id, group.id)
|
||||
);
|
||||
return Promise.all([...disassociatePromises, ...associatePromises]);
|
||||
};
|
||||
|
||||
const submitCredentials = async newCredentials => {
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
template.summary_fields.credentials,
|
||||
@ -130,7 +120,7 @@ function JobTemplateEdit({ template }) {
|
||||
if (!canEdit) {
|
||||
return <Redirect to={detailsUrl} />;
|
||||
}
|
||||
if (isLoading || projectLoading) {
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
|
||||
@ -287,8 +287,8 @@ describe('<JobTemplateEdit />', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobTemplateEdit template={mockJobTemplate} />
|
||||
);
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormGroup[label="Host Config Key"]').length).toBe(1);
|
||||
expect(
|
||||
wrapper.find('FormGroup[label="Host Config Key"]').prop('isRequired')
|
||||
@ -301,8 +301,9 @@ describe('<JobTemplateEdit />', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobTemplateEdit template={mockJobTemplate} />
|
||||
);
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
const updatedTemplateData = {
|
||||
job_type: 'check',
|
||||
name: 'new name',
|
||||
|
||||
@ -72,16 +72,7 @@ function Template({ setBreadcrumb }) {
|
||||
surveyConfiguration = survey;
|
||||
}
|
||||
if (data.summary_fields.credentials) {
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: 200,
|
||||
order_by: 'name',
|
||||
};
|
||||
const {
|
||||
data: { results },
|
||||
} = await JobTemplatesAPI.readCredentials(data.id, params);
|
||||
|
||||
data.summary_fields.credentials = results;
|
||||
data.summary_fields.credentials = defaultCredentials;
|
||||
}
|
||||
|
||||
if (actions.data.actions.PUT) {
|
||||
@ -106,7 +97,7 @@ function Template({ setBreadcrumb }) {
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplateAndRoles();
|
||||
}, [loadTemplateAndRoles, location.pathname]);
|
||||
}, [loadTemplateAndRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
@ -214,7 +205,10 @@ function Template({ setBreadcrumb }) {
|
||||
/>
|
||||
</Route>
|
||||
<Route key="edit" path="/templates/:templateType/:id/edit">
|
||||
<JobTemplateEdit template={template} />
|
||||
<JobTemplateEdit
|
||||
template={template}
|
||||
reloadTemplate={loadTemplateAndRoles}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="access" path="/templates/:templateType/:id/access">
|
||||
<ResourceAccessList
|
||||
|
||||
@ -56,7 +56,7 @@ function JobTemplateForm({
|
||||
setFieldTouched,
|
||||
submitError,
|
||||
validateField,
|
||||
isOverrideDisabledLookup,
|
||||
isOverrideDisabledLookup, // TODO: this is a confusing variable name
|
||||
}) {
|
||||
const [contentError, setContentError] = useState(false);
|
||||
const [allowCallbacks, setAllowCallbacks] = useState(
|
||||
@ -123,7 +123,10 @@ function JobTemplateForm({
|
||||
setFieldValue('instanceGroups', [...data.results]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [setFieldValue, template])
|
||||
}, [setFieldValue, template]),
|
||||
{
|
||||
isLoading: true,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -185,7 +185,7 @@ describe('<JobTemplateForm />', () => {
|
||||
|
||||
test('should update form values on input changes', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
await act(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobTemplateForm
|
||||
template={mockData}
|
||||
@ -193,8 +193,9 @@ describe('<JobTemplateForm />', () => {
|
||||
handleCancel={jest.fn()}
|
||||
/>
|
||||
);
|
||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('input#template-name').simulate('change', {
|
||||
target: { value: 'new foo', name: 'name' },
|
||||
@ -308,6 +309,8 @@ describe('<JobTemplateForm />', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
act(() => {
|
||||
wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
|
||||
true,
|
||||
@ -392,6 +395,8 @@ describe('<JobTemplateForm />', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
expect(
|
||||
wrapper.find('TextInputBase#template-webhook_key').prop('value')
|
||||
).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
|
||||
@ -399,6 +404,7 @@ describe('<JobTemplateForm />', () => {
|
||||
wrapper.find('Button[aria-label="Update webhook key"]').prop('isDisabled')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||
const handleSubmit = jest.fn();
|
||||
let wrapper;
|
||||
|
||||
@ -18,7 +18,7 @@ import useIsMounted from './useIsMounted';
|
||||
export default function useRequest(makeRequest, initialValue) {
|
||||
const [result, setResult] = useState(initialValue);
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(initialValue?.isLoading || false);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return {
|
||||
|
||||
@ -47,11 +47,16 @@ class HasStatus(object):
|
||||
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
|
||||
|
||||
def failure_output_details(self):
|
||||
msg = ''
|
||||
if getattr(self, 'result_stdout', ''):
|
||||
output = bytes_to_str(self.result_stdout)
|
||||
if output:
|
||||
return '\nstdout:\n{}'.format(output)
|
||||
return ''
|
||||
msg = '\nstdout:\n{}'.format(output)
|
||||
if getattr(self, 'job_explanation', ''):
|
||||
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
|
||||
if getattr(self, 'result_traceback', ''):
|
||||
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
|
||||
return msg
|
||||
|
||||
def assert_status(self, status_list, msg=None):
|
||||
if isinstance(status_list, str):
|
||||
@ -65,8 +70,6 @@ class HasStatus(object):
|
||||
else:
|
||||
msg += '\n'
|
||||
msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(self.type.title(), self.id, self.status, status_list)
|
||||
if getattr(self, 'job_explanation', ''):
|
||||
msg += '\njob_explanation: {}'.format(bytes_to_str(self.job_explanation))
|
||||
if getattr(self, 'execution_environment', ''):
|
||||
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
|
||||
if getattr(self, 'related', False):
|
||||
@ -75,19 +78,17 @@ class HasStatus(object):
|
||||
msg += f'\nee_credential: {ee.credential}'
|
||||
msg += f'\nee_pull_option: {ee.pull}'
|
||||
msg += f'\nee_summary_fields: {ee.summary_fields}'
|
||||
if getattr(self, 'result_traceback', ''):
|
||||
msg += '\nresult_traceback:\n{}'.format(bytes_to_str(self.result_traceback))
|
||||
|
||||
msg += self.failure_output_details()
|
||||
|
||||
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
|
||||
try:
|
||||
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
|
||||
dep_output = self.connection.get(
|
||||
'{0}/api/v2/{1}s/{2}/stdout/'.format(self.endpoint.split('/api')[0], data['job_type'], data['job_id']),
|
||||
query_parameters=dict(format='txt_download'),
|
||||
).content
|
||||
msg += '\nDependency output:\n{}'.format(bytes_to_str(dep_output))
|
||||
dependency = self.walk('/api/v2/{0}s/{1}/'.format(data['job_type'], data['job_id']))
|
||||
if hasattr(dependency, 'failure_output_details'):
|
||||
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
|
||||
else:
|
||||
msg += '\nDependency info:\n{}'.format(dependency)
|
||||
except Exception as e:
|
||||
msg += '\nFailed to obtain dependency stdout: {}'.format(e)
|
||||
|
||||
|
||||
@ -66,10 +66,11 @@ In the root of awx-operator:
|
||||
```
|
||||
$ ansible-playbook ansible/instantiate-awx-deployment.yml \
|
||||
-e development_mode=yes \
|
||||
-e tower_image=quay.io/awx/awx_kube_devel \
|
||||
-e tower_image_version=devel \
|
||||
-e tower_image_pull_policy=Always \
|
||||
-e tower_ingress_type=ingress
|
||||
-e image=quay.io/awx/awx_kube_devel \
|
||||
-e image_version=devel \
|
||||
-e image_pull_policy=Always \
|
||||
-e service_type=nodeport \
|
||||
-e namespace=default
|
||||
```
|
||||
|
||||
### Custom AWX Development Image for Kubernetes
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
aiohttp
|
||||
ansible-runner==2.0.0.0b1
|
||||
ansible-runner==2.0.0.0rc3
|
||||
ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading
|
||||
asciichartpy
|
||||
autobahn>=20.12.3 # CVE-2020-35678
|
||||
@ -45,7 +45,7 @@ python3-saml
|
||||
python-dsv-sdk
|
||||
python-ldap>=3.3.1 # https://github.com/python-ldap/python-ldap/issues/270
|
||||
pyyaml>=5.4.1 # minimum to fix https://github.com/yaml/pyyaml/issues/478
|
||||
receptorctl
|
||||
receptorctl==1.0.0.0rc1
|
||||
schedule==0.6.0
|
||||
social-auth-core==3.3.1 # see UPGRADE BLOCKERs
|
||||
social-auth-app-django==3.1.0 # see UPGRADE BLOCKERs
|
||||
|
||||
@ -4,7 +4,7 @@ aiohttp==3.6.2
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
aioredis==1.3.1
|
||||
# via channels-redis
|
||||
ansible-runner==2.0.0.0b1
|
||||
ansible-runner==2.0.0.0rc3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
ansiconv==1.0.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
@ -47,12 +47,12 @@ cachetools==4.0.0
|
||||
# requests
|
||||
cffi==1.14.0
|
||||
# via cryptography
|
||||
channels-redis==3.1.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
channels==2.4.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels-redis
|
||||
channels-redis==3.1.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
chardet==3.0.4
|
||||
# via
|
||||
# aiohttp
|
||||
@ -85,6 +85,19 @@ dictdiffer==0.8.1
|
||||
# via openshift
|
||||
distro==1.5.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django==2.2.16
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels
|
||||
# django-auth-ldap
|
||||
# django-cors-headers
|
||||
# django-crum
|
||||
# django-guid
|
||||
# django-jsonfield
|
||||
# django-oauth-toolkit
|
||||
# django-polymorphic
|
||||
# django-taggit
|
||||
# djangorestframework
|
||||
django-auth-ldap==2.1.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django-cors-headers==3.7.0
|
||||
@ -115,23 +128,10 @@ django-split-settings==1.0.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django-taggit==1.2.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
django==2.2.16
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# channels
|
||||
# django-auth-ldap
|
||||
# django-cors-headers
|
||||
# django-crum
|
||||
# django-guid
|
||||
# django-jsonfield
|
||||
# django-oauth-toolkit
|
||||
# django-polymorphic
|
||||
# django-taggit
|
||||
# djangorestframework
|
||||
djangorestframework-yaml==1.0.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
djangorestframework==3.12.1
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
djangorestframework-yaml==1.0.3
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
docutils==0.16
|
||||
# via python-daemon
|
||||
future==0.16.0
|
||||
@ -237,17 +237,17 @@ psycopg2==2.8.4
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
ptyprocess==0.6.0
|
||||
# via pexpect
|
||||
pyasn1-modules==0.2.8
|
||||
# via
|
||||
# google-auth
|
||||
# python-ldap
|
||||
# service-identity
|
||||
pyasn1==0.4.8
|
||||
# via
|
||||
# pyasn1-modules
|
||||
# python-ldap
|
||||
# rsa
|
||||
# service-identity
|
||||
pyasn1-modules==0.2.8
|
||||
# via
|
||||
# google-auth
|
||||
# python-ldap
|
||||
# service-identity
|
||||
pycparser==2.20
|
||||
# via cffi
|
||||
pygerduty==0.38.2
|
||||
@ -299,17 +299,12 @@ pyyaml==5.4.1
|
||||
# djangorestframework-yaml
|
||||
# kubernetes
|
||||
# receptorctl
|
||||
receptorctl==1.0.0.0a2
|
||||
receptorctl==1.0.0.0rc1
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
redis==3.4.1
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
# django-redis
|
||||
requests-oauthlib==1.3.0
|
||||
# via
|
||||
# kubernetes
|
||||
# msrest
|
||||
# social-auth-core
|
||||
requests==2.23.0
|
||||
# via
|
||||
# -r /awx_devel/requirements/requirements.in
|
||||
@ -323,12 +318,17 @@ requests==2.23.0
|
||||
# slackclient
|
||||
# social-auth-core
|
||||
# twilio
|
||||
requests-oauthlib==1.3.0
|
||||
# via
|
||||
# kubernetes
|
||||
# msrest
|
||||
# social-auth-core
|
||||
rsa==4.0
|
||||
# via google-auth
|
||||
ruamel.yaml.clib==0.2.0
|
||||
# via ruamel.yaml
|
||||
ruamel.yaml==0.16.10
|
||||
# via openshift
|
||||
ruamel.yaml.clib==0.2.0
|
||||
# via ruamel.yaml
|
||||
schedule==0.6.0
|
||||
# via -r /awx_devel/requirements/requirements.in
|
||||
service-identity==18.1.0
|
||||
|
||||
2
setup.py
2
setup.py
@ -154,7 +154,7 @@ setup(
|
||||
"tools/scripts/ansible-tower-setup",
|
||||
],
|
||||
),
|
||||
("%s" % sosconfig, ["tools/sosreport/tower.py"]),
|
||||
("%s" % sosconfig, ["tools/sosreport/controller.py"]),
|
||||
]
|
||||
),
|
||||
options={
|
||||
|
||||
@ -7,7 +7,7 @@ readonly CMDNAME=$(basename "$0")
|
||||
|
||||
readonly MIN_SLEEP=0.5
|
||||
readonly MAX_SLEEP=30
|
||||
readonly ATTEMPTS=10
|
||||
readonly ATTEMPTS=30
|
||||
readonly TIMEOUT=60
|
||||
|
||||
log_message() { echo "[${CMDNAME}]" "$@" >&2; }
|
||||
|
||||
68
tools/sosreport/controller.py
Normal file
68
tools/sosreport/controller.py
Normal file
@ -0,0 +1,68 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
try:
|
||||
from sos.plugins import Plugin, RedHatPlugin
|
||||
except ImportError:
|
||||
from sos.report.plugins import Plugin, RedHatPlugin
|
||||
|
||||
SOSREPORT_CONTROLLER_COMMANDS = [
|
||||
"awx-manage --version", # controller version
|
||||
"awx-manage list_instances", # controller cluster configuration
|
||||
"awx-manage run_dispatcher --status", # controller dispatch worker status
|
||||
"awx-manage run_callback_receiver --status", # controller callback worker status
|
||||
"awx-manage check_license --data", # controller license status
|
||||
"awx-manage run_wsbroadcast --status", # controller broadcast websocket status
|
||||
"supervisorctl status", # controller process status
|
||||
"/var/lib/awx/venv/awx/bin/pip freeze", # pip package list
|
||||
"/var/lib/awx/venv/awx/bin/pip freeze -l", # pip package list without globally-installed packages
|
||||
"/var/lib/awx/venv/ansible/bin/pip freeze", # pip package list
|
||||
"/var/lib/awx/venv/ansible/bin/pip freeze -l", # pip package list without globally-installed packages
|
||||
"tree -d /var/lib/awx", # show me the dirs
|
||||
"ls -ll /var/lib/awx", # check permissions
|
||||
"ls -ll /var/lib/awx/venv", # list all venvs
|
||||
"ls -ll /etc/tower",
|
||||
"umask -p", # check current umask
|
||||
]
|
||||
|
||||
SOSREPORT_CONTROLLER_DIRS = [
|
||||
"/etc/tower/",
|
||||
"/etc/supervisord.d/",
|
||||
"/etc/nginx/",
|
||||
"/var/log/tower",
|
||||
"/var/log/nginx",
|
||||
"/var/log/supervisor",
|
||||
"/var/log/redis",
|
||||
"/etc/opt/rh/rh-redis5/redis.conf",
|
||||
"/etc/redis.conf",
|
||||
"/var/opt/rh/rh-redis5/log/redis/redis.log",
|
||||
"/var/log/dist-upgrade",
|
||||
"/var/log/installer",
|
||||
"/var/log/unattended-upgrades",
|
||||
"/var/log/apport.log",
|
||||
]
|
||||
|
||||
SOSREPORT_FORBIDDEN_PATHS = [
|
||||
"/etc/tower/SECRET_KEY",
|
||||
"/etc/tower/tower.key",
|
||||
"/etc/tower/awx.key",
|
||||
"/etc/tower/tower.cert",
|
||||
"/etc/tower/awx.cert",
|
||||
"/var/log/tower/profile",
|
||||
]
|
||||
|
||||
|
||||
class Controller(Plugin, RedHatPlugin):
|
||||
'''Collect Ansible Automation Platform controller information'''
|
||||
|
||||
plugin_name = "controller"
|
||||
short_desc = "Ansible Automation Platform controller information"
|
||||
|
||||
def setup(self):
|
||||
|
||||
for path in SOSREPORT_CONTROLLER_DIRS:
|
||||
self.add_copy_spec(path)
|
||||
|
||||
self.add_forbidden_path(SOSREPORT_FORBIDDEN_PATHS)
|
||||
|
||||
self.add_cmd_output(SOSREPORT_CONTROLLER_COMMANDS)
|
||||
@ -1,67 +0,0 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
try:
|
||||
from sos.plugins import Plugin, RedHatPlugin, UbuntuPlugin
|
||||
except ImportError:
|
||||
from sos.report.plugins import Plugin, RedHatPlugin, UbuntuPlugin
|
||||
|
||||
SOSREPORT_TOWER_COMMANDS = [
|
||||
"awx-manage --version", # tower version
|
||||
"awx-manage list_instances", # tower cluster configuration
|
||||
"awx-manage run_dispatcher --status", # tower dispatch worker status
|
||||
"awx-manage run_callback_receiver --status", # tower callback worker status
|
||||
"awx-manage check_license --data", # tower license status
|
||||
"awx-manage run_wsbroadcast --status", # tower broadcast websocket status
|
||||
"supervisorctl status", # tower process status
|
||||
"/var/lib/awx/venv/awx/bin/pip freeze", # pip package list
|
||||
"/var/lib/awx/venv/awx/bin/pip freeze -l", # pip package list without globally-installed packages
|
||||
"/var/lib/awx/venv/ansible/bin/pip freeze", # pip package list
|
||||
"/var/lib/awx/venv/ansible/bin/pip freeze -l", # pip package list without globally-installed packages
|
||||
"tree -d /var/lib/awx", # show me the dirs
|
||||
"ls -ll /var/lib/awx", # check permissions
|
||||
"ls -ll /var/lib/awx/venv", # list all venvs
|
||||
"ls -ll /etc/tower",
|
||||
"umask -p" # check current umask
|
||||
]
|
||||
|
||||
SOSREPORT_TOWER_DIRS = [
|
||||
"/etc/tower/",
|
||||
"/etc/supervisord.d/",
|
||||
"/etc/nginx/",
|
||||
"/var/log/tower",
|
||||
"/var/log/nginx",
|
||||
"/var/log/supervisor",
|
||||
"/var/log/redis",
|
||||
"/etc/opt/rh/rh-redis5/redis.conf",
|
||||
"/etc/redis.conf",
|
||||
"/var/opt/rh/rh-redis5/log/redis/redis.log",
|
||||
"/var/log/dist-upgrade",
|
||||
"/var/log/installer",
|
||||
"/var/log/unattended-upgrades",
|
||||
"/var/log/apport.log"
|
||||
]
|
||||
|
||||
SOSREPORT_FORBIDDEN_PATHS = [
|
||||
"/etc/tower/SECRET_KEY",
|
||||
"/etc/tower/tower.key",
|
||||
"/etc/tower/awx.key",
|
||||
"/etc/tower/tower.cert",
|
||||
"/etc/tower/awx.cert",
|
||||
"/var/log/tower/profile"
|
||||
]
|
||||
|
||||
|
||||
class Tower(Plugin, RedHatPlugin, UbuntuPlugin):
|
||||
'''Collect Ansible Tower related information'''
|
||||
plugin_name = "tower"
|
||||
|
||||
def setup(self):
|
||||
|
||||
for path in SOSREPORT_TOWER_DIRS:
|
||||
self.add_copy_spec(path)
|
||||
|
||||
self.add_forbidden_path(SOSREPORT_FORBIDDEN_PATHS)
|
||||
|
||||
self.add_cmd_output(SOSREPORT_TOWER_COMMANDS)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user