mirror of
https://github.com/ansible/awx.git
synced 2026-05-20 23:37:39 -02: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:
@@ -710,8 +710,12 @@ class ExecutionEnvironmentDetail(RetrieveUpdateDestroyAPIView):
|
|||||||
fields_to_check = ['name', 'description', 'organization', 'image', 'credential']
|
fields_to_check = ['name', 'description', 'organization', 'image', 'credential']
|
||||||
if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance):
|
if instance.managed and request.user.can_access(models.ExecutionEnvironment, 'change', instance):
|
||||||
for field in fields_to_check:
|
for field in fields_to_check:
|
||||||
|
if kwargs.get('partial') and field not in request.data:
|
||||||
|
continue
|
||||||
left = getattr(instance, field, None)
|
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:
|
if left != right:
|
||||||
raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments."))
|
raise PermissionDenied(_("Only the 'pull' field can be edited for managed execution environments."))
|
||||||
return super().update(request, *args, **kwargs)
|
return super().update(request, *args, **kwargs)
|
||||||
|
|||||||
@@ -692,6 +692,15 @@ register(
|
|||||||
unit=_('seconds'),
|
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):
|
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'):
|
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',
|
'id': 'secret_path',
|
||||||
'label': _('Path to Secret'),
|
'label': _('Path to Secret'),
|
||||||
'type': 'string',
|
'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',
|
'id': 'auth_path',
|
||||||
|
|||||||
@@ -1246,7 +1246,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def preferred_instance_groups(self):
|
def preferred_instance_groups(self):
|
||||||
return self.global_instance_groups
|
return self.control_plane_instance_group
|
||||||
|
|
||||||
'''
|
'''
|
||||||
JobNotificationMixin
|
JobNotificationMixin
|
||||||
|
|||||||
@@ -1437,7 +1437,12 @@ class UnifiedJob(
|
|||||||
def global_instance_groups(self):
|
def global_instance_groups(self):
|
||||||
from awx.main.models.ha import InstanceGroup
|
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)
|
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.queue import CallbackQueueDispatcher
|
||||||
from awx.main.dispatch.publish import task
|
from awx.main.dispatch.publish import task
|
||||||
from awx.main.dispatch import get_local_queuename, reaper
|
from awx.main.dispatch import get_local_queuename, reaper
|
||||||
from awx.main.utils import (
|
from awx.main.utils.common import (
|
||||||
update_scm_url,
|
update_scm_url,
|
||||||
ignore_inventory_computed_fields,
|
ignore_inventory_computed_fields,
|
||||||
ignore_inventory_group_removal,
|
ignore_inventory_group_removal,
|
||||||
@@ -97,6 +97,7 @@ from awx.main.utils import (
|
|||||||
deepmerge,
|
deepmerge,
|
||||||
parse_yaml_or_json,
|
parse_yaml_or_json,
|
||||||
cleanup_new_process,
|
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.execution_environments import get_default_pod_spec, CONTAINER_ROOT, to_container_path
|
||||||
from awx.main.utils.ansible import read_ansible_config
|
from awx.main.utils.ansible import read_ansible_config
|
||||||
@@ -1267,11 +1268,17 @@ class BaseTask(object):
|
|||||||
for k, v in self.safe_env.items():
|
for k, v in self.safe_env.items():
|
||||||
if k in job_env:
|
if k in job_env:
|
||||||
job_env[k] = v
|
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':
|
elif status_data['status'] == 'error':
|
||||||
result_traceback = status_data.get('result_traceback', None)
|
result_traceback = status_data.get('result_traceback', None)
|
||||||
if result_traceback:
|
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
|
@with_path_cleanup
|
||||||
def run(self, pk, **kwargs):
|
def run(self, pk, **kwargs):
|
||||||
@@ -1791,6 +1798,7 @@ class RunJob(BaseTask):
|
|||||||
if 'update_' not in sync_metafields['job_tags']:
|
if 'update_' not in sync_metafields['job_tags']:
|
||||||
sync_metafields['scm_revision'] = job_revision
|
sync_metafields['scm_revision'] = job_revision
|
||||||
local_project_sync = job.project.create_project_update(_eager_fields=sync_metafields)
|
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
|
# save the associated job before calling run() so that a
|
||||||
# cancel() call on the job can cancel the project update
|
# cancel() call on the job can cancel the project update
|
||||||
job = self.update_model(job.pk, project_update=local_project_sync)
|
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():
|
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))
|
logger.debug('Skipping SCM inventory update for `{}` because ' 'another update is already active.'.format(inv_src.name))
|
||||||
continue
|
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(
|
local_inv_update = inv_src.create_inventory_update(
|
||||||
_eager_fields=dict(
|
_eager_fields=dict(
|
||||||
launch_type='scm',
|
launch_type='scm',
|
||||||
status='running',
|
status='running',
|
||||||
instance_group=project_update.instance_group,
|
instance_group=instance_group,
|
||||||
execution_node=project_update.execution_node,
|
execution_node=project_update.execution_node,
|
||||||
source_project_update=project_update,
|
source_project_update=project_update,
|
||||||
celery_task_id=project_update.celery_task_id,
|
celery_task_id=project_update.celery_task_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
create_partition(local_inv_update.event_class._meta.db_table, start=local_inv_update.created)
|
||||||
inv_update_class().run(local_inv_update.id)
|
inv_update_class().run(local_inv_update.id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('{} Unhandled exception updating dependent SCM inventory sources.'.format(project_update.log_format))
|
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):
|
if not os.path.exists(settings.PROJECTS_ROOT):
|
||||||
os.mkdir(settings.PROJECTS_ROOT)
|
os.mkdir(settings.PROJECTS_ROOT)
|
||||||
project_path = instance.project.get_project_path(check_if_exists=False)
|
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)
|
self.acquire_lock(instance)
|
||||||
|
|
||||||
@@ -2175,6 +2188,9 @@ class RunProjectUpdate(BaseTask):
|
|||||||
else:
|
else:
|
||||||
self.original_branch = git_repo.active_branch
|
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')
|
stage_path = os.path.join(instance.get_cache_path(), 'stage')
|
||||||
if os.path.exists(stage_path):
|
if os.path.exists(stage_path):
|
||||||
logger.warning('{0} unexpectedly existed before update'.format(stage_path))
|
logger.warning('{0} unexpectedly existed before update'.format(stage_path))
|
||||||
@@ -2988,7 +3004,8 @@ class AWXReceptorJob:
|
|||||||
if state_name == 'Succeeded':
|
if state_name == 'Succeeded':
|
||||||
return res
|
return res
|
||||||
|
|
||||||
raise RuntimeError(detail)
|
if self.task.instance.result_traceback is None:
|
||||||
|
raise RuntimeError(detail)
|
||||||
|
|
||||||
return res
|
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]
|
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)
|
ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, policy_instance_percentage=percentage)
|
||||||
if instance is not None:
|
if instance is not None:
|
||||||
if type(instance) == list:
|
if type(instance) == list:
|
||||||
|
|||||||
@@ -35,18 +35,19 @@ class TestDependentInventoryUpdate:
|
|||||||
task.post_run_hook(proj_update, 'successful')
|
task.post_run_hook(proj_update, 'successful')
|
||||||
assert not inv_update_mck.called
|
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()
|
task = RunProjectUpdate()
|
||||||
scm_inventory_source.scm_last_revision = ''
|
scm_inventory_source.scm_last_revision = ''
|
||||||
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
|
proj_update = ProjectUpdate.objects.create(project=scm_inventory_source.source_project)
|
||||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
||||||
task._update_dependent_inventories(proj_update, [scm_inventory_source])
|
with mock.patch('awx.main.tasks.create_partition'):
|
||||||
assert InventoryUpdate.objects.count() == 1
|
task._update_dependent_inventories(proj_update, [scm_inventory_source])
|
||||||
inv_update = InventoryUpdate.objects.first()
|
assert InventoryUpdate.objects.count() == 1
|
||||||
iu_run_mock.assert_called_once_with(inv_update.id)
|
inv_update = InventoryUpdate.objects.first()
|
||||||
assert inv_update.source_project_update_id == proj_update.pk
|
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
|
Test that dependent inventory updates exhibit good behavior on cancel
|
||||||
of the source project update
|
of the source project update
|
||||||
@@ -63,8 +64,9 @@ class TestDependentInventoryUpdate:
|
|||||||
ProjectUpdate.objects.all().update(cancel_flag=True)
|
ProjectUpdate.objects.all().update(cancel_flag=True)
|
||||||
|
|
||||||
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
with mock.patch.object(RunInventoryUpdate, 'run') as iu_run_mock:
|
||||||
iu_run_mock.side_effect = user_cancels_project
|
with mock.patch('awx.main.tasks.create_partition'):
|
||||||
task._update_dependent_inventories(proj_update, [is1, is2])
|
iu_run_mock.side_effect = user_cancels_project
|
||||||
# Verify that it bails after 1st update, detecting a cancel
|
task._update_dependent_inventories(proj_update, [is1, is2])
|
||||||
assert is2.inventory_updates.count() == 0
|
# Verify that it bails after 1st update, detecting a cancel
|
||||||
iu_run_mock.assert_called_once()
|
assert is2.inventory_updates.count() == 0
|
||||||
|
iu_run_mock.assert_called_once()
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"aria-labelledby",
|
"aria-labelledby",
|
||||||
"aria-hidden",
|
"aria-hidden",
|
||||||
"aria-controls",
|
"aria-controls",
|
||||||
|
"aria-pressed",
|
||||||
"sortKey",
|
"sortKey",
|
||||||
"ouiaId",
|
"ouiaId",
|
||||||
"credentialTypeNamespace",
|
"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 =>
|
const InstanceGroupsMixin = parent =>
|
||||||
class extends parent {
|
class extends parent {
|
||||||
readInstanceGroups(resourceId, params) {
|
readInstanceGroups(resourceId, params) {
|
||||||
@@ -18,6 +27,20 @@ const InstanceGroupsMixin = parent =>
|
|||||||
disassociate: true,
|
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;
|
export default InstanceGroupsMixin;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ class Settings extends Base {
|
|||||||
return this.http.patch(`${this.baseUrl}all/`, data);
|
return this.http.patch(`${this.baseUrl}all/`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readAll() {
|
||||||
|
return this.http.get(`${this.baseUrl}all/`);
|
||||||
|
}
|
||||||
|
|
||||||
updateCategory(category, data) {
|
updateCategory(category, data) {
|
||||||
return this.http.patch(`${this.baseUrl}${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 { SearchColumns, SortColumns } from '../../types';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
import DataListToolbar from '../DataListToolbar';
|
||||||
import CheckboxListItem from '../CheckboxListItem';
|
import CheckboxListItem from '../CheckboxListItem';
|
||||||
import SelectedList from '../SelectedList';
|
import { SelectedList } from '../SelectedList';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';
|
import PaginatedTable, { HeaderCell, HeaderRow } from '../PaginatedTable';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
import CheckboxCard from './CheckboxCard';
|
import CheckboxCard from './CheckboxCard';
|
||||||
import SelectedList from '../SelectedList';
|
import { SelectedList } from '../SelectedList';
|
||||||
|
|
||||||
function RolesStep({
|
function RolesStep({
|
||||||
onRolesClick,
|
onRolesClick,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
ToolbarContent,
|
ToolbarContent as PFToolbarContent,
|
||||||
ToolbarGroup,
|
ToolbarGroup,
|
||||||
ToolbarItem,
|
ToolbarItem,
|
||||||
ToolbarToggleGroup,
|
ToolbarToggleGroup,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
DropdownPosition,
|
||||||
KebabToggle,
|
KebabToggle,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
import { SearchIcon } from '@patternfly/react-icons';
|
import { SearchIcon } from '@patternfly/react-icons';
|
||||||
@@ -19,6 +21,12 @@ import Sort from '../Sort';
|
|||||||
import { SearchColumns, SortColumns, QSConfig } from '../../types';
|
import { SearchColumns, SortColumns, QSConfig } from '../../types';
|
||||||
import { KebabifiedProvider } from '../../contexts/Kebabified';
|
import { KebabifiedProvider } from '../../contexts/Kebabified';
|
||||||
|
|
||||||
|
const ToolbarContent = styled(PFToolbarContent)`
|
||||||
|
& > .pf-c-toolbar__content-section {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
function DataListToolbar({
|
function DataListToolbar({
|
||||||
itemCount,
|
itemCount,
|
||||||
clearAllFilters,
|
clearAllFilters,
|
||||||
@@ -47,6 +55,11 @@ function DataListToolbar({
|
|||||||
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
|
const [isKebabModalOpen, setIsKebabModalOpen] = useState(false);
|
||||||
const [isAdvancedSearchShown, setIsAdvancedSearchShown] = 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 => {
|
const onShowAdvancedSearch = shown => {
|
||||||
setIsAdvancedSearchShown(shown);
|
setIsAdvancedSearchShown(shown);
|
||||||
setIsKebabOpen(false);
|
setIsKebabOpen(false);
|
||||||
@@ -135,6 +148,7 @@ function DataListToolbar({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
isOpen={isKebabOpen}
|
isOpen={isKebabOpen}
|
||||||
|
position={dropdownPosition}
|
||||||
isPlain
|
isPlain
|
||||||
dropdownItems={additionalControls}
|
dropdownItems={additionalControls}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
function PasswordInput(props) {
|
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 [inputType, setInputType] = useState('password');
|
||||||
const [field, meta] = useField({ name, validate });
|
const [field, meta] = useField({ name, validate });
|
||||||
|
|
||||||
@@ -44,7 +52,7 @@ function PasswordInput(props) {
|
|||||||
value={field.value === '$encrypted$' ? '' : field.value}
|
value={field.value === '$encrypted$' ? '' : field.value}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
validated={isValid ? 'default' : 'error'}
|
validated={isValid || isFieldGroupValid ? 'default' : 'error'}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
onChange={(_, event) => {
|
onChange={(_, event) => {
|
||||||
field.onChange(event);
|
field.onChange(event);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const InventoryLookupField = ({ isDisabled }) => {
|
|||||||
error={inventoryMeta.error}
|
error={inventoryMeta.error}
|
||||||
validate={required(t`Select a value for this field`)}
|
validate={required(t`Select a value for this field`)}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
hideSmartInventories
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,22 +29,24 @@ const QS_CONFIG = getQSConfig('credentials', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function CredentialLookup({
|
function CredentialLookup({
|
||||||
helperTextInvalid,
|
autoPopulate,
|
||||||
label,
|
|
||||||
isValid,
|
|
||||||
onBlur,
|
|
||||||
onChange,
|
|
||||||
required,
|
|
||||||
credentialTypeId,
|
credentialTypeId,
|
||||||
credentialTypeKind,
|
credentialTypeKind,
|
||||||
credentialTypeNamespace,
|
credentialTypeNamespace,
|
||||||
value,
|
|
||||||
tooltip,
|
|
||||||
isDisabled,
|
|
||||||
autoPopulate,
|
|
||||||
multiple,
|
|
||||||
validate,
|
|
||||||
fieldName,
|
fieldName,
|
||||||
|
helperTextInvalid,
|
||||||
|
isDisabled,
|
||||||
|
isSelectedDraggable,
|
||||||
|
isValid,
|
||||||
|
label,
|
||||||
|
modalDescription,
|
||||||
|
multiple,
|
||||||
|
onBlur,
|
||||||
|
onChange,
|
||||||
|
required,
|
||||||
|
tooltip,
|
||||||
|
validate,
|
||||||
|
value,
|
||||||
}) {
|
}) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||||
@@ -174,6 +176,7 @@ function CredentialLookup({
|
|||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
modalDescription={modalDescription}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
value={state.selectedItems}
|
value={state.selectedItems}
|
||||||
@@ -208,7 +211,11 @@ function CredentialLookup({
|
|||||||
name="credential"
|
name="credential"
|
||||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
|
||||||
|
sortSelectedItems={selectedItems =>
|
||||||
|
dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems })
|
||||||
|
}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
isSelectedDraggable={isSelectedDraggable}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function ExecutionEnvironmentLookup({
|
|||||||
const {
|
const {
|
||||||
request: fetchProject,
|
request: fetchProject,
|
||||||
error: fetchProjectError,
|
error: fetchProjectError,
|
||||||
isLoading: fetchProjectLoading,
|
isLoading: isProjectLoading,
|
||||||
result: project,
|
result: project,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
@@ -53,6 +53,7 @@ function ExecutionEnvironmentLookup({
|
|||||||
}, [projectId]),
|
}, [projectId]),
|
||||||
{
|
{
|
||||||
project: null,
|
project: null,
|
||||||
|
isLoading: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -72,6 +73,12 @@ function ExecutionEnvironmentLookup({
|
|||||||
isLoading,
|
isLoading,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
|
if (isProjectLoading) {
|
||||||
|
return {
|
||||||
|
executionEnvironments: [],
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(QS_CONFIG, location.search);
|
||||||
const globallyAvailableParams = globallyAvailable
|
const globallyAvailableParams = globallyAvailable
|
||||||
? { or__organization__isnull: 'True' }
|
? { or__organization__isnull: 'True' }
|
||||||
@@ -105,7 +112,14 @@ function ExecutionEnvironmentLookup({
|
|||||||
actionsResponse.data.actions?.GET || {}
|
actionsResponse.data.actions?.GET || {}
|
||||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [location, globallyAvailable, organizationId, projectId, project]),
|
}, [
|
||||||
|
location,
|
||||||
|
globallyAvailable,
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
project,
|
||||||
|
isProjectLoading,
|
||||||
|
]),
|
||||||
{
|
{
|
||||||
executionEnvironments: [],
|
executionEnvironments: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -149,7 +163,7 @@ function ExecutionEnvironmentLookup({
|
|||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
validate={validate}
|
validate={validate}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
isLoading={isLoading || fetchProjectLoading}
|
isLoading={isLoading || isProjectLoading}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
renderOptionsList={({ state, dispatch, canDelete }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ describe('ExecutionEnvironmentLookup', () => {
|
|||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ExecutionEnvironmentsAPI.read.mockResolvedValue(
|
ExecutionEnvironmentsAPI.read.mockResolvedValue({
|
||||||
mockedExecutionEnvironments
|
data: mockedExecutionEnvironments,
|
||||||
);
|
});
|
||||||
ProjectsAPI.readDetail.mockResolvedValue({ data: { organization: 39 } });
|
ProjectsAPI.readDetail.mockResolvedValue({ data: { organization: 39 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ describe('ExecutionEnvironmentLookup', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2);
|
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1);
|
expect(wrapper.find('ExecutionEnvironmentLookup')).toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
||||||
@@ -84,7 +84,7 @@ describe('ExecutionEnvironmentLookup', () => {
|
|||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(2);
|
expect(ExecutionEnvironmentsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
wrapper.find('FormGroup[label="Default Execution Environment"]').length
|
||||||
).toBe(0);
|
).toBe(0);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect } from 'react';
|
|||||||
import { arrayOf, string, func, bool } from 'prop-types';
|
import { arrayOf, string, func, bool } from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t, Trans } from '@lingui/macro';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
import { InstanceGroupsAPI } from '../../api';
|
import { InstanceGroupsAPI } from '../../api';
|
||||||
import { InstanceGroup } from '../../types';
|
import { InstanceGroup } from '../../types';
|
||||||
@@ -82,6 +82,18 @@ function InstanceGroupsLookup({
|
|||||||
multiple
|
multiple
|
||||||
required={required}
|
required={required}
|
||||||
isLoading={isLoading}
|
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 }) => (
|
renderOptionsList={({ state, dispatch, canDelete }) => (
|
||||||
<OptionsList
|
<OptionsList
|
||||||
value={state.selectedItems}
|
value={state.selectedItems}
|
||||||
@@ -113,6 +125,10 @@ function InstanceGroupsLookup({
|
|||||||
readOnly={!canDelete}
|
readOnly={!canDelete}
|
||||||
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
|
||||||
deselectItem={item => dispatch({ type: 'DESELECT_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 Lookup from './Lookup';
|
||||||
import OptionsList from '../OptionsList';
|
import OptionsList from '../OptionsList';
|
||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString, mergeParams } from '../../util/qs';
|
||||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
import FieldWithPrompt from '../FieldWithPrompt';
|
import FieldWithPrompt from '../FieldWithPrompt';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ function InventoryLookup({
|
|||||||
validate,
|
validate,
|
||||||
fieldName,
|
fieldName,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
hideSmartInventories,
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
result: {
|
result: {
|
||||||
@@ -47,8 +48,15 @@ function InventoryLookup({
|
|||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, history.location.search);
|
const params = parseQueryString(QS_CONFIG, history.location.search);
|
||||||
|
const inventoryKindParams = hideSmartInventories
|
||||||
|
? { not__kind: 'smart' }
|
||||||
|
: {};
|
||||||
const [{ data }, actionsResponse] = await Promise.all([
|
const [{ data }, actionsResponse] = await Promise.all([
|
||||||
InventoriesAPI.read(params),
|
InventoriesAPI.read(
|
||||||
|
mergeParams(params, {
|
||||||
|
...inventoryKindParams,
|
||||||
|
})
|
||||||
|
),
|
||||||
InventoriesAPI.readOptions(),
|
InventoriesAPI.readOptions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -60,7 +68,12 @@ function InventoryLookup({
|
|||||||
).map(val => val.slice(0, -8)),
|
).map(val => val.slice(0, -8)),
|
||||||
searchableKeys: Object.keys(
|
searchableKeys: Object.keys(
|
||||||
actionsResponse.data.actions?.GET || {}
|
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:
|
canEdit:
|
||||||
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||||
};
|
};
|
||||||
@@ -230,6 +243,7 @@ InventoryLookup.propTypes = {
|
|||||||
validate: func,
|
validate: func,
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
isDisabled: bool,
|
isDisabled: bool,
|
||||||
|
hideSmartInventories: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
InventoryLookup.defaultProps = {
|
InventoryLookup.defaultProps = {
|
||||||
@@ -239,6 +253,7 @@ InventoryLookup.defaultProps = {
|
|||||||
validate: () => {},
|
validate: () => {},
|
||||||
fieldName: 'inventory',
|
fieldName: 'inventory',
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
|
hideSmartInventories: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withRouter(InventoryLookup);
|
export default withRouter(InventoryLookup);
|
||||||
|
|||||||
@@ -48,6 +48,42 @@ describe('InventoryLookup', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
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('InventoryLookup')).toHaveLength(1);
|
||||||
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ function Lookup(props) {
|
|||||||
onDebounce,
|
onDebounce,
|
||||||
fieldName,
|
fieldName,
|
||||||
validate,
|
validate,
|
||||||
|
modalDescription,
|
||||||
} = props;
|
} = props;
|
||||||
const [typedText, setTypedText] = useState('');
|
const [typedText, setTypedText] = useState('');
|
||||||
const debounceRequest = useDebounce(onDebounce, 1000);
|
const debounceRequest = useDebounce(onDebounce, 1000);
|
||||||
@@ -166,6 +167,7 @@ function Lookup(props) {
|
|||||||
aria-label={t`Lookup modal`}
|
aria-label={t`Lookup modal`}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
|
description={state?.selectedItems?.length > 0 && modalDescription}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button
|
||||||
ouiaId="modal-select-button"
|
ouiaId="modal-select-button"
|
||||||
@@ -204,6 +206,7 @@ const Item = shape({
|
|||||||
Lookup.propTypes = {
|
Lookup.propTypes = {
|
||||||
id: string,
|
id: string,
|
||||||
header: string,
|
header: string,
|
||||||
|
modalDescription: string,
|
||||||
onChange: func.isRequired,
|
onChange: func.isRequired,
|
||||||
value: oneOfType([Item, arrayOf(Item)]),
|
value: oneOfType([Item, arrayOf(Item)]),
|
||||||
multiple: bool,
|
multiple: bool,
|
||||||
@@ -224,6 +227,7 @@ Lookup.defaultProps = {
|
|||||||
value: null,
|
value: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
required: false,
|
required: false,
|
||||||
|
modalDescription: '',
|
||||||
onBlur: () => {},
|
onBlur: () => {},
|
||||||
renderItemChip: ({ item, removeItem, canDelete }) => (
|
renderItemChip: ({ item, removeItem, canDelete }) => (
|
||||||
<Chip
|
<Chip
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from 'prop-types';
|
} from 'prop-types';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import SelectedList from '../SelectedList';
|
import { SelectedList, DraggableSelectedList } from '../SelectedList';
|
||||||
import CheckboxListItem from '../CheckboxListItem';
|
import CheckboxListItem from '../CheckboxListItem';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
import DataListToolbar from '../DataListToolbar';
|
||||||
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
||||||
@@ -23,28 +23,39 @@ const ModalList = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
function OptionsList({
|
function OptionsList({
|
||||||
value,
|
|
||||||
contentError,
|
contentError,
|
||||||
options,
|
deselectItem,
|
||||||
optionCount,
|
displayKey,
|
||||||
searchColumns,
|
|
||||||
sortColumns,
|
|
||||||
searchableKeys,
|
|
||||||
relatedSearchableKeys,
|
|
||||||
multiple,
|
|
||||||
header,
|
header,
|
||||||
|
isLoading,
|
||||||
|
isSelectedDraggable,
|
||||||
|
multiple,
|
||||||
name,
|
name,
|
||||||
|
optionCount,
|
||||||
|
options,
|
||||||
qsConfig,
|
qsConfig,
|
||||||
readOnly,
|
readOnly,
|
||||||
selectItem,
|
relatedSearchableKeys,
|
||||||
deselectItem,
|
|
||||||
renderItemChip,
|
renderItemChip,
|
||||||
isLoading,
|
searchColumns,
|
||||||
displayKey,
|
searchableKeys,
|
||||||
|
selectItem,
|
||||||
|
sortColumns,
|
||||||
|
sortSelectedItems,
|
||||||
|
value,
|
||||||
}) {
|
}) {
|
||||||
return (
|
let selectionPreview = null;
|
||||||
<ModalList>
|
if (value.length > 0) {
|
||||||
{value.length > 0 && (
|
if (isSelectedDraggable) {
|
||||||
|
selectionPreview = (
|
||||||
|
<DraggableSelectedList
|
||||||
|
onRemove={deselectItem}
|
||||||
|
onRowDrag={sortSelectedItems}
|
||||||
|
selected={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectionPreview = (
|
||||||
<SelectedList
|
<SelectedList
|
||||||
label={t`Selected`}
|
label={t`Selected`}
|
||||||
selected={value}
|
selected={value}
|
||||||
@@ -53,7 +64,13 @@ function OptionsList({
|
|||||||
renderItemChip={renderItemChip}
|
renderItemChip={renderItemChip}
|
||||||
displayKey={displayKey}
|
displayKey={displayKey}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalList>
|
||||||
|
{selectionPreview}
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
items={options}
|
items={options}
|
||||||
@@ -99,6 +116,7 @@ const Item = shape({
|
|||||||
OptionsList.propTypes = {
|
OptionsList.propTypes = {
|
||||||
deselectItem: func.isRequired,
|
deselectItem: func.isRequired,
|
||||||
displayKey: string,
|
displayKey: string,
|
||||||
|
isSelectedDraggable: bool,
|
||||||
multiple: bool,
|
multiple: bool,
|
||||||
optionCount: number.isRequired,
|
optionCount: number.isRequired,
|
||||||
options: arrayOf(Item).isRequired,
|
options: arrayOf(Item).isRequired,
|
||||||
@@ -110,6 +128,7 @@ OptionsList.propTypes = {
|
|||||||
value: arrayOf(Item).isRequired,
|
value: arrayOf(Item).isRequired,
|
||||||
};
|
};
|
||||||
OptionsList.defaultProps = {
|
OptionsList.defaultProps = {
|
||||||
|
isSelectedDraggable: false,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
renderItemChip: null,
|
renderItemChip: null,
|
||||||
searchColumns: [],
|
searchColumns: [],
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const AdvancedGroup = styled.div`
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
|
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .pf-c-select {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function AdvancedSearch({
|
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 ReactDOM from 'react-dom';
|
||||||
import './setupCSP';
|
import './setupCSP';
|
||||||
import '@patternfly/react-core/dist/styles/base.css';
|
import '@patternfly/react-core/dist/styles/base.css';
|
||||||
|
import './border.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
ReactDOM.render(
|
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;
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
|
function CredentialInput({
|
||||||
|
fieldOptions,
|
||||||
|
isFieldGroupValid,
|
||||||
|
credentialKind,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
const [fileIsUploading, setFileIsUploading] = useState(false);
|
const [fileIsUploading, setFileIsUploading] = useState(false);
|
||||||
const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
const [subFormField, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
||||||
@@ -116,6 +121,7 @@ function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
|
|||||||
<>
|
<>
|
||||||
{RevertReplaceButton}
|
{RevertReplaceButton}
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
|
isFieldGroupValid={isFieldGroupValid}
|
||||||
{...subFormField}
|
{...subFormField}
|
||||||
id={`credential-${fieldOptions.id}`}
|
id={`credential-${fieldOptions.id}`}
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -169,7 +175,9 @@ function CredentialField({ credentialType, fieldOptions }) {
|
|||||||
name: `inputs.${fieldOptions.id}`,
|
name: `inputs.${fieldOptions.id}`,
|
||||||
validate: validateField(),
|
validate: validateField(),
|
||||||
});
|
});
|
||||||
const isValid = !(meta.touched && meta.error);
|
const isValid =
|
||||||
|
!(meta.touched && meta.error) ||
|
||||||
|
formikValues.passwordPrompts[fieldOptions.id];
|
||||||
|
|
||||||
if (fieldOptions.choices) {
|
if (fieldOptions.choices) {
|
||||||
const selectOptions = fieldOptions.choices.map(choice => {
|
const selectOptions = fieldOptions.choices.map(choice => {
|
||||||
@@ -235,7 +243,10 @@ function CredentialField({ credentialType, fieldOptions }) {
|
|||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
validated={isValid ? 'default' : 'error'}
|
validated={isValid ? 'default' : 'error'}
|
||||||
>
|
>
|
||||||
<CredentialInput fieldOptions={fieldOptions} />
|
<CredentialInput
|
||||||
|
isFieldGroupValid={isValid}
|
||||||
|
fieldOptions={fieldOptions}
|
||||||
|
/>
|
||||||
</CredentialPluginField>
|
</CredentialPluginField>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,15 +98,23 @@ function CredentialPluginField(props) {
|
|||||||
const [, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
const [, meta, helpers] = useField(`inputs.${fieldOptions.id}`);
|
||||||
const [passwordPromptField] = useField(`passwordPrompts.${fieldOptions.id}`);
|
const [passwordPromptField] = useField(`passwordPrompts.${fieldOptions.id}`);
|
||||||
|
|
||||||
const invalidHelperTextToDisplay = meta.error && meta.touched && (
|
let invalidHelperTextToDisplay;
|
||||||
<div
|
|
||||||
className={css(styles.formHelperText, styles.modifiers.error)}
|
if (meta.error && meta.touched) {
|
||||||
id={`${fieldOptions.id}-helper`}
|
invalidHelperTextToDisplay = (
|
||||||
aria-live="polite"
|
<div
|
||||||
>
|
className={css(styles.formHelperText, styles.modifiers.error)}
|
||||||
{meta.error}
|
id={`${fieldOptions.id}-helper`}
|
||||||
</div>
|
aria-live="polite"
|
||||||
);
|
>
|
||||||
|
{meta.error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldOptions.id === 'vault_password' && passwordPromptField.value) {
|
||||||
|
invalidHelperTextToDisplay = null;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (passwordPromptField.value) {
|
if (passwordPromptField.value) {
|
||||||
@@ -119,8 +127,6 @@ function CredentialPluginField(props) {
|
|||||||
<>
|
<>
|
||||||
{fieldOptions.ask_at_runtime ? (
|
{fieldOptions.ask_at_runtime ? (
|
||||||
<FieldWithPrompt
|
<FieldWithPrompt
|
||||||
fieldId={`credential-${fieldOptions.id}`}
|
|
||||||
helperTextInvalid={meta.error}
|
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
label={fieldOptions.label}
|
label={fieldOptions.label}
|
||||||
promptId={`credential-prompt-${fieldOptions.id}`}
|
promptId={`credential-prompt-${fieldOptions.id}`}
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ function ExecutionEnvironmentFormFields({
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
|
autoPopulate={!me?.is_superuser ? !executionEnvironment?.id : null}
|
||||||
isDisabled={!!isOrgLookupDisabled && isGloballyAvailable.current}
|
isDisabled={
|
||||||
|
(!!isOrgLookupDisabled && isGloballyAvailable.current) ||
|
||||||
|
executionEnvironment?.managed
|
||||||
|
}
|
||||||
validate={
|
validate={
|
||||||
!me?.is_superuser
|
!me?.is_superuser
|
||||||
? required(t`Select a value for this field`)
|
? required(t`Select a value for this field`)
|
||||||
@@ -93,6 +96,7 @@ function ExecutionEnvironmentFormFields({
|
|||||||
type="text"
|
type="text"
|
||||||
validate={required(null)}
|
validate={required(null)}
|
||||||
isRequired
|
isRequired
|
||||||
|
isDisabled={executionEnvironment?.managed || false}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
id="execution-environment-image"
|
id="execution-environment-image"
|
||||||
@@ -101,6 +105,7 @@ function ExecutionEnvironmentFormFields({
|
|||||||
type="text"
|
type="text"
|
||||||
validate={required(null)}
|
validate={required(null)}
|
||||||
isRequired
|
isRequired
|
||||||
|
isDisabled={executionEnvironment?.managed || false}
|
||||||
tooltip={
|
tooltip={
|
||||||
<span>
|
<span>
|
||||||
{t`The full image location, including the container registry, image name, and version tag.`}
|
{t`The full image location, including the container registry, image name, and version tag.`}
|
||||||
@@ -142,6 +147,7 @@ function ExecutionEnvironmentFormFields({
|
|||||||
label={t`Description`}
|
label={t`Description`}
|
||||||
name="description"
|
name="description"
|
||||||
type="text"
|
type="text"
|
||||||
|
isDisabled={executionEnvironment?.managed || false}
|
||||||
/>
|
/>
|
||||||
{isOrgLookupDisabled && isGloballyAvailable.current ? (
|
{isOrgLookupDisabled && isGloballyAvailable.current ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -162,6 +168,7 @@ function ExecutionEnvironmentFormFields({
|
|||||||
onChange={onCredentialChange}
|
onChange={onCredentialChange}
|
||||||
value={credentialField.value}
|
value={credentialField.value}
|
||||||
tooltip={t`Credential to authenticate with a protected container registry.`}
|
tooltip={t`Credential to authenticate with a protected container registry.`}
|
||||||
|
isDisabled={executionEnvironment?.managed || false}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -271,4 +271,46 @@ describe('<ExecutionEnvironmentForm/>', () => {
|
|||||||
newWrapper.update();
|
newWrapper.update();
|
||||||
expect(newWrapper.find('OrganizationLookup').prop('value')).toEqual(null);
|
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 { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
import { InstanceGroupsAPI } from '../../api';
|
import { InstanceGroupsAPI, SettingsAPI } from '../../api';
|
||||||
import RoutedTabs from '../../components/RoutedTabs';
|
import RoutedTabs from '../../components/RoutedTabs';
|
||||||
import ContentError from '../../components/ContentError';
|
import ContentError from '../../components/ContentError';
|
||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
@@ -30,12 +30,24 @@ function ContainerGroup({ setBreadcrumb }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
request: fetchInstanceGroups,
|
request: fetchInstanceGroups,
|
||||||
result: instanceGroup,
|
result: { instanceGroup, defaultExecution },
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const { data } = await InstanceGroupsAPI.readDetail(id);
|
const [
|
||||||
return data;
|
{ data },
|
||||||
}, [id])
|
{
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -109,10 +121,16 @@ function ContainerGroup({ setBreadcrumb }) {
|
|||||||
{instanceGroup && (
|
{instanceGroup && (
|
||||||
<>
|
<>
|
||||||
<Route path="/instance_groups/container_group/:id/edit">
|
<Route path="/instance_groups/container_group/:id/edit">
|
||||||
<ContainerGroupEdit instanceGroup={instanceGroup} />
|
<ContainerGroupEdit
|
||||||
|
instanceGroup={instanceGroup}
|
||||||
|
defaultExecution={defaultExecution}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/container_group/:id/details">
|
<Route path="/instance_groups/container_group/:id/details">
|
||||||
<ContainerGroupDetails instanceGroup={instanceGroup} />
|
<ContainerGroupDetails
|
||||||
|
instanceGroup={instanceGroup}
|
||||||
|
defaultExecution={defaultExecution}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/container_group/:id/jobs">
|
<Route path="/instance_groups/container_group/:id/jobs">
|
||||||
<JobList
|
<JobList
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, Label } from '@patternfly/react-core';
|
|||||||
|
|
||||||
import { VariablesDetail } from '../../../components/CodeEditor';
|
import { VariablesDetail } from '../../../components/CodeEditor';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
import DeleteButton from '../../../components/DeleteButton';
|
import DeleteButton from '../../../components/DeleteButton';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,7 @@ import { jsonToYaml, isJsonString } from '../../../util/yaml';
|
|||||||
import { InstanceGroupsAPI } from '../../../api';
|
import { InstanceGroupsAPI } from '../../../api';
|
||||||
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function ContainerGroupDetails({ instanceGroup }) {
|
function ContainerGroupDetails({ instanceGroup, defaultExecution }) {
|
||||||
const { id, name } = instanceGroup;
|
const { id, name } = instanceGroup;
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -102,7 +103,8 @@ function ContainerGroupDetails({ instanceGroup }) {
|
|||||||
{t`Edit`}
|
{t`Edit`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{instanceGroup.summary_fields.user_capabilities &&
|
{name !== defaultExecution &&
|
||||||
|
instanceGroup.summary_fields.user_capabilities &&
|
||||||
instanceGroup.summary_fields.user_capabilities.delete && (
|
instanceGroup.summary_fields.user_capabilities.delete && (
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
ouiaId="container-group-detail-delete-button"
|
ouiaId="container-group-detail-delete-button"
|
||||||
@@ -123,7 +125,9 @@ function ContainerGroupDetails({ instanceGroup }) {
|
|||||||
onClose={dismissError}
|
onClose={dismissError}
|
||||||
title={t`Error`}
|
title={t`Error`}
|
||||||
variant="error"
|
variant="error"
|
||||||
/>
|
>
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { CaretLeftIcon } from '@patternfly/react-icons';
|
|||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
|
|
||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
import { InstanceGroupsAPI } from '../../api';
|
import { InstanceGroupsAPI, SettingsAPI } from '../../api';
|
||||||
import RoutedTabs from '../../components/RoutedTabs';
|
import RoutedTabs from '../../components/RoutedTabs';
|
||||||
import ContentError from '../../components/ContentError';
|
import ContentError from '../../components/ContentError';
|
||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
@@ -31,12 +31,24 @@ function InstanceGroup({ setBreadcrumb }) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
request: fetchInstanceGroups,
|
request: fetchInstanceGroups,
|
||||||
result: instanceGroup,
|
result: { instanceGroup, defaultControlPlane },
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const { data } = await InstanceGroupsAPI.readDetail(id);
|
const [
|
||||||
return data;
|
{ data },
|
||||||
}, [id])
|
{
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -115,10 +127,16 @@ function InstanceGroup({ setBreadcrumb }) {
|
|||||||
{instanceGroup && (
|
{instanceGroup && (
|
||||||
<>
|
<>
|
||||||
<Route path="/instance_groups/:id/edit">
|
<Route path="/instance_groups/:id/edit">
|
||||||
<InstanceGroupEdit instanceGroup={instanceGroup} />
|
<InstanceGroupEdit
|
||||||
|
instanceGroup={instanceGroup}
|
||||||
|
defaultControlPlane={defaultControlPlane}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/details">
|
<Route path="/instance_groups/:id/details">
|
||||||
<InstanceGroupDetails instanceGroup={instanceGroup} />
|
<InstanceGroupDetails
|
||||||
|
defaultControlPlane={defaultControlPlane}
|
||||||
|
instanceGroup={instanceGroup}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/:id/instances">
|
<Route path="/instance_groups/:id/instances">
|
||||||
<InstanceList />
|
<InstanceList />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@patternfly/react-core';
|
|||||||
|
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import DeleteButton from '../../../components/DeleteButton';
|
import DeleteButton from '../../../components/DeleteButton';
|
||||||
import {
|
import {
|
||||||
Detail,
|
Detail,
|
||||||
@@ -22,7 +23,7 @@ const Unavailable = styled.span`
|
|||||||
color: var(--pf-global--danger-color--200);
|
color: var(--pf-global--danger-color--200);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function InstanceGroupDetails({ instanceGroup }) {
|
function InstanceGroupDetails({ instanceGroup, defaultControlPlane }) {
|
||||||
const { id, name } = instanceGroup;
|
const { id, name } = instanceGroup;
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -110,7 +111,7 @@ function InstanceGroupDetails({ instanceGroup }) {
|
|||||||
{t`Edit`}
|
{t`Edit`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{name !== 'tower' &&
|
{name !== defaultControlPlane &&
|
||||||
instanceGroup.summary_fields.user_capabilities &&
|
instanceGroup.summary_fields.user_capabilities &&
|
||||||
instanceGroup.summary_fields.user_capabilities.delete && (
|
instanceGroup.summary_fields.user_capabilities.delete && (
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
@@ -132,7 +133,9 @@ function InstanceGroupDetails({ instanceGroup }) {
|
|||||||
onClose={dismissError}
|
onClose={dismissError}
|
||||||
title={t`Error`}
|
title={t`Error`}
|
||||||
variant="error"
|
variant="error"
|
||||||
/>
|
>
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { CardBody } from '../../../components/Card';
|
|||||||
import { InstanceGroupsAPI } from '../../../api';
|
import { InstanceGroupsAPI } from '../../../api';
|
||||||
import InstanceGroupForm from '../shared/InstanceGroupForm';
|
import InstanceGroupForm from '../shared/InstanceGroupForm';
|
||||||
|
|
||||||
function InstanceGroupEdit({ instanceGroup }) {
|
function InstanceGroupEdit({ instanceGroup, defaultControlPlane }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [submitError, setSubmitError] = useState(null);
|
const [submitError, setSubmitError] = useState(null);
|
||||||
const detailsUrl = `/instance_groups/${instanceGroup.id}/details`;
|
const detailsUrl = `/instance_groups/${instanceGroup.id}/details`;
|
||||||
@@ -27,6 +27,7 @@ function InstanceGroupEdit({ instanceGroup }) {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<InstanceGroupForm
|
<InstanceGroupForm
|
||||||
instanceGroup={instanceGroup}
|
instanceGroup={instanceGroup}
|
||||||
|
defaultControlPlane={defaultControlPlane}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitError={submitError}
|
submitError={submitError}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ describe('<InstanceGroupEdit>', () => {
|
|||||||
history = createMemoryHistory();
|
history = createMemoryHistory();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<InstanceGroupEdit instanceGroup={instanceGroupData} />,
|
<InstanceGroupEdit
|
||||||
|
defaultControlPlane="controlplane"
|
||||||
|
instanceGroup={instanceGroupData}
|
||||||
|
/>,
|
||||||
{
|
{
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
}
|
}
|
||||||
@@ -68,12 +71,13 @@ describe('<InstanceGroupEdit>', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tower instance group name can not be updated', async () => {
|
test('controlplane instance group name can not be updated', async () => {
|
||||||
let towerWrapper;
|
let towerWrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
towerWrapper = mountWithContexts(
|
towerWrapper = mountWithContexts(
|
||||||
<InstanceGroupEdit
|
<InstanceGroupEdit
|
||||||
instanceGroup={{ ...instanceGroupData, name: 'tower' }}
|
defaultControlPlane="controlplane"
|
||||||
|
instanceGroup={{ ...instanceGroupData, name: 'controlplane' }}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
@@ -85,7 +89,7 @@ describe('<InstanceGroupEdit>', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
towerWrapper.find('input#instance-group-name').prop('value')
|
towerWrapper.find('input#instance-group-name').prop('value')
|
||||||
).toEqual('tower');
|
).toEqual('controlplane');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleSubmit should call the api and redirect to details page', async () => {
|
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 { t, Plural } from '@lingui/macro';
|
||||||
import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
|
import { Card, PageSection, DropdownItem } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { InstanceGroupsAPI } from '../../../api';
|
import { InstanceGroupsAPI, SettingsAPI } from '../../../api';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import useSelected from '../../../util/useSelected';
|
import useSelected from '../../../util/useSelected';
|
||||||
import PaginatedTable, {
|
import PaginatedTable, {
|
||||||
HeaderRow,
|
HeaderRow,
|
||||||
HeaderCell,
|
HeaderCell,
|
||||||
|
ToolbarAddButton,
|
||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedTable';
|
} from '../../../components/PaginatedTable';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
@@ -25,7 +26,11 @@ const QS_CONFIG = getQSConfig('instance-group', {
|
|||||||
page_size: 20,
|
page_size: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
function modifyInstanceGroups(items = []) {
|
function modifyInstanceGroups(
|
||||||
|
items = [],
|
||||||
|
defaultControlPlane,
|
||||||
|
defaultExecution
|
||||||
|
) {
|
||||||
return items.map(item => {
|
return items.map(item => {
|
||||||
const clonedItem = {
|
const clonedItem = {
|
||||||
...item,
|
...item,
|
||||||
@@ -36,16 +41,44 @@ function modifyInstanceGroups(items = []) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (clonedItem.name === 'tower') {
|
if (clonedItem.name === (defaultControlPlane || defaultExecution)) {
|
||||||
clonedItem.summary_fields.user_capabilities.delete = false;
|
clonedItem.summary_fields.user_capabilities.delete = false;
|
||||||
}
|
}
|
||||||
return clonedItem;
|
return clonedItem;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstanceGroupList() {
|
function InstanceGroupList({
|
||||||
|
isKubernetes,
|
||||||
|
isSettingsRequestLoading,
|
||||||
|
settingsRequestError,
|
||||||
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch();
|
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 {
|
const {
|
||||||
error: contentError,
|
error: contentError,
|
||||||
@@ -100,7 +133,11 @@ function InstanceGroupList() {
|
|||||||
selectAll,
|
selectAll,
|
||||||
} = useSelected(instanceGroups);
|
} = useSelected(instanceGroups);
|
||||||
|
|
||||||
const modifiedSelected = modifyInstanceGroups(selected);
|
const modifiedSelected = modifyInstanceGroups(
|
||||||
|
selected,
|
||||||
|
defaultControlPlane,
|
||||||
|
defaultExecution
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading: deleteLoading,
|
isLoading: deleteLoading,
|
||||||
@@ -128,63 +165,66 @@ function InstanceGroupList() {
|
|||||||
const canAdd = actions && actions.POST;
|
const canAdd = actions && actions.POST;
|
||||||
|
|
||||||
function cannotDelete(item) {
|
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`;
|
const pluralizedItemName = t`Instance Groups`;
|
||||||
|
|
||||||
let errorMessageDelete = '';
|
let errorMessageDelete = '';
|
||||||
|
const notdeletedable = selected.filter(
|
||||||
|
i => i.name === defaultControlPlane || i.name === defaultExecution
|
||||||
|
);
|
||||||
|
|
||||||
if (modifiedSelected.some(item => item.name === 'tower')) {
|
if (notdeletedable.length) {
|
||||||
const itemsUnableToDelete = modifiedSelected
|
errorMessageDelete = (
|
||||||
.filter(cannotDelete)
|
<Plural
|
||||||
.filter(item => item.name !== 'tower')
|
value={notdeletedable.length}
|
||||||
.map(item => item.name)
|
one="The following Instance Group cannot be deleted"
|
||||||
.join(', ');
|
other="The following Instance Groups cannot be deleted"
|
||||||
|
/>
|
||||||
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.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const addContainerGroup = t`Add container group`;
|
const addContainerGroup = t`Add container group`;
|
||||||
const addInstanceGroup = t`Add instance group`;
|
const addInstanceGroup = t`Add instance group`;
|
||||||
|
|
||||||
const addButton = (
|
const addButton =
|
||||||
<AddDropDownButton
|
!isSettingsRequestLoading && !isKubernetes ? (
|
||||||
ouiaId="add-instance-group-button"
|
<AddDropDownButton
|
||||||
key="add"
|
ouiaId="add-instance-group-button"
|
||||||
dropdownItems={[
|
key="add"
|
||||||
<DropdownItem
|
dropdownItems={[
|
||||||
ouiaId="add-container-group-item"
|
<DropdownItem
|
||||||
to="/instance_groups/container_group/add"
|
ouiaId="add-container-group-item"
|
||||||
component={Link}
|
to="/instance_groups/container_group/add"
|
||||||
key={addContainerGroup}
|
component={Link}
|
||||||
aria-label={addContainerGroup}
|
key={addContainerGroup}
|
||||||
>
|
aria-label={addContainerGroup}
|
||||||
{addContainerGroup}
|
>
|
||||||
</DropdownItem>,
|
{addContainerGroup}
|
||||||
<DropdownItem
|
</DropdownItem>,
|
||||||
ouiaId="add-instance-group-item"
|
<DropdownItem
|
||||||
to="/instance_groups/add"
|
ouiaId="add-instance-group-item"
|
||||||
component={Link}
|
to="/instance_groups/add"
|
||||||
key={addInstanceGroup}
|
component={Link}
|
||||||
aria-label={addInstanceGroup}
|
key={addInstanceGroup}
|
||||||
>
|
aria-label={addInstanceGroup}
|
||||||
{addInstanceGroup}
|
>
|
||||||
</DropdownItem>,
|
{addInstanceGroup}
|
||||||
]}
|
</DropdownItem>,
|
||||||
/>
|
]}
|
||||||
);
|
/>
|
||||||
|
) : (
|
||||||
|
<ToolbarAddButton
|
||||||
|
key="add"
|
||||||
|
ouiaId="add-container-group-button"
|
||||||
|
linkTo={`${match.url}/container_group/add`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const getDetailUrl = item => {
|
const getDetailUrl = item => {
|
||||||
return item.is_container_group
|
return item.is_container_group
|
||||||
@@ -199,8 +239,15 @@ function InstanceGroupList() {
|
|||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedTable
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={
|
||||||
hasContentLoading={isLoading || deleteLoading}
|
contentError || settingsRequestError || protectedItemsError
|
||||||
|
}
|
||||||
|
hasContentLoading={
|
||||||
|
isLoading ||
|
||||||
|
deleteLoading ||
|
||||||
|
isSettingsRequestLoading ||
|
||||||
|
isLoadingProtectedItems
|
||||||
|
}
|
||||||
items={instanceGroups}
|
items={instanceGroups}
|
||||||
itemCount={instanceGroupsCount}
|
itemCount={instanceGroupsCount}
|
||||||
pluralizedItemName={pluralizedItemName}
|
pluralizedItemName={pluralizedItemName}
|
||||||
@@ -220,6 +267,7 @@ function InstanceGroupList() {
|
|||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
cannotDelete={cannotDelete}
|
||||||
itemsToDelete={modifiedSelected}
|
itemsToDelete={modifiedSelected}
|
||||||
pluralizedItemName={t`Instance Groups`}
|
pluralizedItemName={t`Instance Groups`}
|
||||||
errorMessage={errorMessageDelete}
|
errorMessage={errorMessageDelete}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
OrganizationsAPI,
|
OrganizationsAPI,
|
||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
|
SettingsAPI,
|
||||||
} from '../../../api';
|
} from '../../../api';
|
||||||
import InstanceGroupList from './InstanceGroupList';
|
import InstanceGroupList from './InstanceGroupList';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ jest.mock('../../../api/models/InstanceGroups');
|
|||||||
jest.mock('../../../api/models/Organizations');
|
jest.mock('../../../api/models/Organizations');
|
||||||
jest.mock('../../../api/models/Inventories');
|
jest.mock('../../../api/models/Inventories');
|
||||||
jest.mock('../../../api/models/UnifiedJobTemplates');
|
jest.mock('../../../api/models/UnifiedJobTemplates');
|
||||||
|
jest.mock('../../../api/models/Settings');
|
||||||
|
|
||||||
const instanceGroups = {
|
const instanceGroups = {
|
||||||
data: {
|
data: {
|
||||||
@@ -32,7 +34,7 @@ const instanceGroups = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'tower',
|
name: 'controlplan',
|
||||||
type: 'instance_group',
|
type: 'instance_group',
|
||||||
url: '/api/v2/instance_groups/2',
|
url: '/api/v2/instance_groups/2',
|
||||||
consumed_capacity: 42,
|
consumed_capacity: 42,
|
||||||
@@ -40,6 +42,14 @@ const instanceGroups = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
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',
|
name: 'Bar',
|
||||||
type: 'instance_group',
|
type: 'instance_group',
|
||||||
url: '/api/v2/instance_groups/3',
|
url: '/api/v2/instance_groups/3',
|
||||||
@@ -47,11 +57,17 @@ const instanceGroups = {
|
|||||||
summary_fields: { user_capabilities: { edit: true, delete: false } },
|
summary_fields: { user_capabilities: { edit: true, delete: false } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
count: 3,
|
count: 4,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = { data: { actions: { POST: true } } };
|
const options = { data: { actions: { POST: true } } };
|
||||||
|
const settings = {
|
||||||
|
data: {
|
||||||
|
DEFAULT_CONTROL_PLANE_QUEUE_NAME: 'controlplan',
|
||||||
|
DEFAULT_EXECUTION_QUEUE_NAME: 'default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe('<InstanceGroupList />', () => {
|
describe('<InstanceGroupList />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
@@ -62,6 +78,7 @@ describe('<InstanceGroupList />', () => {
|
|||||||
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
InstanceGroupsAPI.read.mockResolvedValue(instanceGroups);
|
InstanceGroupsAPI.read.mockResolvedValue(instanceGroups);
|
||||||
InstanceGroupsAPI.readOptions.mockResolvedValue(options);
|
InstanceGroupsAPI.readOptions.mockResolvedValue(options);
|
||||||
|
SettingsAPI.readAll.mockResolvedValue(settings);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should have data fetched and render 3 rows', async () => {
|
test('should have data fetched and render 3 rows', async () => {
|
||||||
@@ -69,7 +86,7 @@ describe('<InstanceGroupList />', () => {
|
|||||||
wrapper = mountWithContexts(<InstanceGroupList />);
|
wrapper = mountWithContexts(<InstanceGroupList />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
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.read).toBeCalled();
|
||||||
expect(InstanceGroupsAPI.readOptions).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 () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<InstanceGroupList />);
|
wrapper = mountWithContexts(<InstanceGroupList />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
await waitForElement(wrapper, 'InstanceGroupList', el => el.length > 0);
|
||||||
|
|
||||||
const instanceGroupIndex = [0, 1, 2];
|
const instanceGroupIndex = [0, 1, 2, 3];
|
||||||
|
|
||||||
instanceGroupIndex.forEach(element => {
|
instanceGroupIndex.forEach(element => {
|
||||||
wrapper
|
wrapper
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../api';
|
||||||
|
|
||||||
import InstanceGroupAdd from './InstanceGroupAdd';
|
import InstanceGroupAdd from './InstanceGroupAdd';
|
||||||
import InstanceGroupList from './InstanceGroupList';
|
import InstanceGroupList from './InstanceGroupList';
|
||||||
import InstanceGroup from './InstanceGroup';
|
import InstanceGroup from './InstanceGroup';
|
||||||
|
|
||||||
import ContainerGroupAdd from './ContainerGroupAdd';
|
import ContainerGroupAdd from './ContainerGroupAdd';
|
||||||
import ContainerGroup from './ContainerGroup';
|
import ContainerGroup from './ContainerGroup';
|
||||||
import ScreenHeader from '../../components/ScreenHeader';
|
import ScreenHeader from '../../components/ScreenHeader';
|
||||||
|
|
||||||
function InstanceGroups() {
|
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({
|
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
|
||||||
'/instance_groups': t`Instance Groups`,
|
'/instance_groups': t`Instance Groups`,
|
||||||
'/instance_groups/add': t`Create new instance group`,
|
'/instance_groups/add': t`Create new instance group`,
|
||||||
@@ -39,6 +59,7 @@ function InstanceGroups() {
|
|||||||
[`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
[`/instance_groups/container_group/${instanceGroups.id}`]: `${instanceGroups.name}`,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
@@ -52,14 +73,20 @@ function InstanceGroups() {
|
|||||||
<Route path="/instance_groups/container_group/:id">
|
<Route path="/instance_groups/container_group/:id">
|
||||||
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
|
<ContainerGroup setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups/add">
|
{!isSettingsRequestLoading && !isKubernetes ? (
|
||||||
<InstanceGroupAdd />
|
<Route path="/instance_groups/add">
|
||||||
</Route>
|
<InstanceGroupAdd />
|
||||||
|
</Route>
|
||||||
|
) : null}
|
||||||
<Route path="/instance_groups/:id">
|
<Route path="/instance_groups/:id">
|
||||||
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
|
<InstanceGroup setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/instance_groups">
|
<Route path="/instance_groups">
|
||||||
<InstanceGroupList />
|
<InstanceGroupList
|
||||||
|
isKubernetes={isKubernetes}
|
||||||
|
isSettingsRequestLoading={isSettingsRequestLoading}
|
||||||
|
settingsRequestError={settingsRequestError}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ function ContainerGroupFormFields({ instanceGroup }) {
|
|||||||
'credential'
|
'credential'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [nameField] = useField('name');
|
||||||
|
|
||||||
const [overrideField] = useField('override');
|
const [overrideField] = useField('override');
|
||||||
|
|
||||||
const handleCredentialUpdate = useCallback(
|
const handleCredentialUpdate = useCallback(
|
||||||
@@ -45,6 +47,7 @@ function ContainerGroupFormFields({ instanceGroup }) {
|
|||||||
label={t`Name`}
|
label={t`Name`}
|
||||||
type="text"
|
type="text"
|
||||||
validate={required(null)}
|
validate={required(null)}
|
||||||
|
isDisabled={nameField.value === 'default'}
|
||||||
isRequired
|
isRequired
|
||||||
/>
|
/>
|
||||||
<CredentialLookup
|
<CredentialLookup
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import FormActionGroup from '../../../components/FormActionGroup';
|
|||||||
import { required, minMaxValue } from '../../../util/validators';
|
import { required, minMaxValue } from '../../../util/validators';
|
||||||
import { FormColumnLayout } from '../../../components/FormLayout';
|
import { FormColumnLayout } from '../../../components/FormLayout';
|
||||||
|
|
||||||
function InstanceGroupFormFields() {
|
function InstanceGroupFormFields({ defaultControlPlane }) {
|
||||||
const [instanceGroupNameField, ,] = useField('name');
|
const [instanceGroupNameField, ,] = useField('name');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -21,7 +22,7 @@ function InstanceGroupFormFields() {
|
|||||||
type="text"
|
type="text"
|
||||||
validate={required(null)}
|
validate={required(null)}
|
||||||
isRequired
|
isRequired
|
||||||
isDisabled={instanceGroupNameField.value === 'tower'}
|
isDisabled={instanceGroupNameField.value === defaultControlPlane}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
id="instance-group-policy-instance-minimum"
|
id="instance-group-policy-instance-minimum"
|
||||||
@@ -50,6 +51,7 @@ function InstanceGroupFormFields() {
|
|||||||
|
|
||||||
function InstanceGroupForm({
|
function InstanceGroupForm({
|
||||||
instanceGroup = {},
|
instanceGroup = {},
|
||||||
|
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
submitError,
|
submitError,
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ function InventoryAdd() {
|
|||||||
organization: organization.id,
|
organization: organization.id,
|
||||||
...remainingValues,
|
...remainingValues,
|
||||||
});
|
});
|
||||||
if (instanceGroups) {
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
const associatePromises = instanceGroups.map(async ig =>
|
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||||
InventoriesAPI.associateInstanceGroup(inventoryId, ig.id)
|
for (const group of instanceGroups) {
|
||||||
);
|
await InventoriesAPI.associateInstanceGroup(inventoryId, group.id);
|
||||||
await Promise.all(associatePromises);
|
|
||||||
}
|
}
|
||||||
|
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||||
const url = history.location.pathname.startsWith(
|
const url = history.location.pathname.startsWith(
|
||||||
'/inventories/smart_inventory'
|
'/inventories/smart_inventory'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { CardBody } from '../../../components/Card';
|
|||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
import InventoryForm from '../shared/InventoryForm';
|
import InventoryForm from '../shared/InventoryForm';
|
||||||
import { getAddedAndRemoved } from '../../../util/lists';
|
|
||||||
import useIsMounted from '../../../util/useIsMounted';
|
import useIsMounted from '../../../util/useIsMounted';
|
||||||
|
|
||||||
function InventoryEdit({ inventory }) {
|
function InventoryEdit({ inventory }) {
|
||||||
@@ -54,20 +53,12 @@ function InventoryEdit({ inventory }) {
|
|||||||
organization: organization.id,
|
organization: organization.id,
|
||||||
...remainingValues,
|
...remainingValues,
|
||||||
});
|
});
|
||||||
if (instanceGroups) {
|
await InventoriesAPI.orderInstanceGroups(
|
||||||
const { added, removed } = getAddedAndRemoved(
|
inventory.id,
|
||||||
associatedInstanceGroups,
|
instanceGroups,
|
||||||
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 =
|
const url =
|
||||||
history.location.pathname.search('smart') > -1
|
history.location.pathname.search('smart') > -1
|
||||||
? `/inventories/smart_inventory/${inventory.id}/details`
|
? `/inventories/smart_inventory/${inventory.id}/details`
|
||||||
|
|||||||
@@ -106,17 +106,10 @@ describe('<InventoryEdit />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
await sleep(0);
|
await sleep(0);
|
||||||
instanceGroups.map(IG =>
|
expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledWith(
|
||||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(
|
mockInventory.id,
|
||||||
1,
|
instanceGroups,
|
||||||
IG.id
|
associatedInstanceGroups
|
||||||
)
|
|
||||||
);
|
|
||||||
associatedInstanceGroups.map(async aIG =>
|
|
||||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
|
|
||||||
1,
|
|
||||||
aIG.id
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ function InventoryList() {
|
|||||||
key: 'name__icontains',
|
key: 'name__icontains',
|
||||||
isDefault: true,
|
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`,
|
name: t`Description`,
|
||||||
key: 'description__icontains',
|
key: 'description__icontains',
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ function SmartInventoryAdd() {
|
|||||||
data: { id: invId },
|
data: { id: invId },
|
||||||
} = await InventoriesAPI.create(values);
|
} = await InventoriesAPI.create(values);
|
||||||
|
|
||||||
await Promise.all(
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
groupsToAssociate.map(({ id }) =>
|
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||||
InventoriesAPI.associateInstanceGroup(invId, id)
|
for (const group of groupsToAssociate) {
|
||||||
)
|
await InventoriesAPI.associateInstanceGroup(invId, group.id);
|
||||||
);
|
}
|
||||||
|
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||||
return invId;
|
return invId;
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Inventory } from '../../../types';
|
import { Inventory } from '../../../types';
|
||||||
import { getAddedAndRemoved } from '../../../util/lists';
|
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
@@ -17,7 +16,7 @@ function SmartInventoryEdit({ inventory }) {
|
|||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading: hasContentLoading,
|
isLoading: hasContentLoading,
|
||||||
request: fetchInstanceGroups,
|
request: fetchInstanceGroups,
|
||||||
result: instanceGroups,
|
result: initialInstanceGroups,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const {
|
const {
|
||||||
@@ -40,15 +39,10 @@ function SmartInventoryEdit({ inventory }) {
|
|||||||
useCallback(
|
useCallback(
|
||||||
async (values, groupsToAssociate, groupsToDisassociate) => {
|
async (values, groupsToAssociate, groupsToDisassociate) => {
|
||||||
const { data } = await InventoriesAPI.update(inventory.id, values);
|
const { data } = await InventoriesAPI.update(inventory.id, values);
|
||||||
await Promise.all(
|
await InventoriesAPI.orderInstanceGroups(
|
||||||
groupsToAssociate.map(id =>
|
inventory.id,
|
||||||
InventoriesAPI.associateInstanceGroup(inventory.id, id)
|
groupsToAssociate,
|
||||||
)
|
groupsToDisassociate
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
groupsToDisassociate.map(id =>
|
|
||||||
InventoriesAPI.disassociateInstanceGroup(inventory.id, id)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -68,20 +62,13 @@ function SmartInventoryEdit({ inventory }) {
|
|||||||
const handleSubmit = async form => {
|
const handleSubmit = async form => {
|
||||||
const { instance_groups, organization, ...remainingForm } = 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(
|
await submitRequest(
|
||||||
{
|
{
|
||||||
organization: organization?.id,
|
organization: organization?.id,
|
||||||
...remainingForm,
|
...remainingForm,
|
||||||
},
|
},
|
||||||
addedIds,
|
instance_groups,
|
||||||
removedIds
|
initialInstanceGroups
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,7 +91,7 @@ function SmartInventoryEdit({ inventory }) {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<SmartInventoryForm
|
<SmartInventoryForm
|
||||||
inventory={inventory}
|
inventory={inventory}
|
||||||
instanceGroups={instanceGroups}
|
instanceGroups={initialInstanceGroups}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitError={submitError}
|
submitError={submitError}
|
||||||
|
|||||||
@@ -104,8 +104,7 @@ describe('<SmartInventoryEdit />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
||||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
|
expect(InventoriesAPI.orderInstanceGroups).toHaveBeenCalledTimes(1);
|
||||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('successful form submission should trigger redirect to details', async () => {
|
test('successful form submission should trigger redirect to details', async () => {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function JobDetail({ job }) {
|
|||||||
workflow_job_template: workflowJobTemplate,
|
workflow_job_template: workflowJobTemplate,
|
||||||
labels,
|
labels,
|
||||||
project,
|
project,
|
||||||
|
project_update: projectUpdate,
|
||||||
source_workflow_job,
|
source_workflow_job,
|
||||||
execution_environment: executionEnvironment,
|
execution_environment: executionEnvironment,
|
||||||
} = job.summary_fields;
|
} = 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 (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
@@ -199,15 +219,7 @@ function JobDetail({ job }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{project && (
|
{project && (
|
||||||
<Detail
|
<Detail label={t`Project`} value={buildProjectDetailValue()} />
|
||||||
label={t`Project`}
|
|
||||||
value={
|
|
||||||
<StatusDetailValue>
|
|
||||||
{project.status && <StatusIcon status={project.status} />}
|
|
||||||
<Link to={`/projects/${project.id}`}>{project.name}</Link>
|
|
||||||
</StatusDetailValue>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{scmBranch && (
|
{scmBranch && (
|
||||||
<Detail
|
<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 {
|
try {
|
||||||
const [
|
const [
|
||||||
{
|
{
|
||||||
data: { results: fetchedEvents = [] },
|
data: { results: fetchedEvents = [] },
|
||||||
},
|
},
|
||||||
{
|
count,
|
||||||
data: { results: lastEvents = [] },
|
] = await Promise.all([eventPromise, countRequest()]);
|
||||||
},
|
|
||||||
] = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
let countOffset = 0;
|
let countOffset = 0;
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ import mockFilteredJobEventsData from './data.filtered_job_events.json';
|
|||||||
|
|
||||||
jest.mock('../../../api');
|
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 generateChattyRows = () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
'',
|
'',
|
||||||
@@ -82,24 +98,13 @@ const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
|||||||
describe('<JobOutput />', () => {
|
describe('<JobOutput />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const mockJob = mockJobData;
|
const mockJob = mockJobData;
|
||||||
const mockJobEvents = mockJobEventsData;
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
JobsAPI.readEvents = (jobId, params) => {
|
applyJobEventMock(mockJobEventsData);
|
||||||
const [...results] = mockJobEvents.results;
|
|
||||||
if (params.order_by && params.order_by.includes('-')) {
|
|
||||||
results.reverse();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
results,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
wrapper.unmount();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders successfully', async () => {
|
test('initially renders successfully', async () => {
|
||||||
@@ -141,7 +146,7 @@ describe('<JobOutput />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
jobEvents = wrapper.find('JobEvent');
|
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'
|
'\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 () => {
|
await act(async () => {
|
||||||
@@ -149,10 +154,10 @@ describe('<JobOutput />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
jobEvents = wrapper.find('JobEvent');
|
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'
|
'\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'
|
'\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 () => {
|
await act(async () => {
|
||||||
@@ -169,7 +174,7 @@ describe('<JobOutput />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
jobEvents = wrapper.find('JobEvent');
|
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'
|
'\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(
|
Object.defineProperty(
|
||||||
@@ -266,11 +271,7 @@ describe('<JobOutput />', () => {
|
|||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
||||||
JobsAPI.readEvents = jest.fn();
|
applyJobEventMock(mockFilteredJobEventsData);
|
||||||
JobsAPI.readEvents.mockClear();
|
|
||||||
JobsAPI.readEvents.mockResolvedValueOnce({
|
|
||||||
data: mockFilteredJobEventsData,
|
|
||||||
});
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find(searchTextInput).instance().value = '99';
|
wrapper.find(searchTextInput).instance().value = '99';
|
||||||
wrapper.find(searchTextInput).simulate('change');
|
wrapper.find(searchTextInput).simulate('change');
|
||||||
@@ -281,14 +282,13 @@ describe('<JobOutput />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(JobsAPI.readEvents).toHaveBeenCalled();
|
expect(JobsAPI.readEvents).toHaveBeenCalled();
|
||||||
// TODO: Fix these assertions
|
const jobEvents = wrapper.find('JobEvent');
|
||||||
// const jobEvents = wrapper.find('JobEvent');
|
expect(jobEvents.at(0).prop('stdout')).toBe(
|
||||||
// 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'
|
||||||
// '\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(
|
||||||
// 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'
|
||||||
// '\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 () => {
|
test('should throw error', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import React from 'react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button as PFButton } from '@patternfly/react-core';
|
import { Button as PFButton } from '@patternfly/react-core';
|
||||||
import {
|
import {
|
||||||
PlusIcon,
|
|
||||||
AngleDoubleUpIcon,
|
AngleDoubleUpIcon,
|
||||||
AngleDoubleDownIcon,
|
AngleDoubleDownIcon,
|
||||||
AngleUpIcon,
|
AngleUpIcon,
|
||||||
@@ -17,6 +16,7 @@ const Wrapper = styled.div`
|
|||||||
height: 35px;
|
height: 35px;
|
||||||
outline: 1px solid #d7d7d7;
|
outline: 1px solid #d7d7d7;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Button = styled(PFButton)`
|
const Button = styled(PFButton)`
|
||||||
@@ -31,14 +31,6 @@ const PageControls = ({
|
|||||||
onScrollPrevious,
|
onScrollPrevious,
|
||||||
}) => (
|
}) => (
|
||||||
<Wrapper>
|
<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
|
<Button
|
||||||
ouiaId="job-output-scroll-previous-button"
|
ouiaId="job-output-scroll-previous-button"
|
||||||
aria-label={t`Scroll previous`}
|
aria-label={t`Scroll previous`}
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
|||||||
import PageControls from './PageControls';
|
import PageControls from './PageControls';
|
||||||
|
|
||||||
let wrapper;
|
let wrapper;
|
||||||
let PlusIcon;
|
|
||||||
let AngleDoubleUpIcon;
|
let AngleDoubleUpIcon;
|
||||||
let AngleDoubleDownIcon;
|
let AngleDoubleDownIcon;
|
||||||
let AngleUpIcon;
|
let AngleUpIcon;
|
||||||
let AngleDownIcon;
|
let AngleDownIcon;
|
||||||
|
|
||||||
const findChildren = () => {
|
const findChildren = () => {
|
||||||
PlusIcon = wrapper.find('PlusIcon');
|
|
||||||
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
|
AngleDoubleUpIcon = wrapper.find('AngleDoubleUpIcon');
|
||||||
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
|
AngleDoubleDownIcon = wrapper.find('AngleDoubleDownIcon');
|
||||||
AngleUpIcon = wrapper.find('AngleUpIcon');
|
AngleUpIcon = wrapper.find('AngleUpIcon');
|
||||||
@@ -26,7 +24,6 @@ describe('PageControls', () => {
|
|||||||
test('should render menu control icons', () => {
|
test('should render menu control icons', () => {
|
||||||
wrapper = mountWithContexts(<PageControls />);
|
wrapper = mountWithContexts(<PageControls />);
|
||||||
findChildren();
|
findChildren();
|
||||||
expect(PlusIcon.length).toBe(1);
|
|
||||||
expect(AngleDoubleUpIcon.length).toBe(1);
|
expect(AngleDoubleUpIcon.length).toBe(1);
|
||||||
expect(AngleDoubleDownIcon.length).toBe(1);
|
expect(AngleDoubleDownIcon.length).toBe(1);
|
||||||
expect(AngleUpIcon.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';
|
import { constants as wfConstants } from '../../../components/Workflow/WorkflowUtils';
|
||||||
|
|
||||||
const NodeG = styled.g`
|
const NodeG = styled.g`
|
||||||
cursor: ${props =>
|
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||||
props.job && props.job.type !== 'workflow_approval'
|
|
||||||
? 'pointer'
|
|
||||||
: 'default'};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const JobTopLine = styled.div`
|
const JobTopLine = styled.div`
|
||||||
@@ -90,8 +87,10 @@ function WorkflowOutputNode({ mouseEnter, mouseLeave, node }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNodeClick = () => {
|
const handleNodeClick = () => {
|
||||||
if (job && job.type !== 'workflow_aproval') {
|
if (job) {
|
||||||
history.push(`/jobs/${job.id}/details`);
|
const basePath =
|
||||||
|
job.type !== 'workflow_approval' ? 'jobs' : 'workflow_approvals';
|
||||||
|
history.push(`/${basePath}/${job.id}/details`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,15 +41,19 @@ function OrganizationAdd() {
|
|||||||
...values,
|
...values,
|
||||||
default_environment: values.default_environment?.id,
|
default_environment: values.default_environment?.id,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
groupsToAssociate
|
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||||
.map(id => OrganizationsAPI.associateInstanceGroup(response.id, id))
|
for (const group of groupsToAssociate) {
|
||||||
.concat(
|
await OrganizationsAPI.associateInstanceGroup(response.id, group.id);
|
||||||
values.galaxy_credentials.map(({ id: credId }) =>
|
}
|
||||||
OrganizationsAPI.associateGalaxyCredential(response.id, credId)
|
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}`);
|
history.push(`/organizations/${response.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFormError(error);
|
setFormError(error);
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ describe('<OrganizationAdd />', () => {
|
|||||||
description: 'new description',
|
description: 'new description',
|
||||||
galaxy_credentials: [],
|
galaxy_credentials: [],
|
||||||
};
|
};
|
||||||
|
const mockInstanceGroups = [
|
||||||
|
{
|
||||||
|
name: 'mock ig',
|
||||||
|
id: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
OrganizationsAPI.create.mockResolvedValueOnce({
|
OrganizationsAPI.create.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
id: 5,
|
id: 5,
|
||||||
@@ -109,7 +115,10 @@ describe('<OrganizationAdd />', () => {
|
|||||||
wrapper = mountWithContexts(<OrganizationAdd />);
|
wrapper = mountWithContexts(<OrganizationAdd />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'button[aria-label="Save"]');
|
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);
|
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(5, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import PropTypes from 'prop-types';
|
|||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
import { OrganizationsAPI } from '../../../api';
|
import { OrganizationsAPI } from '../../../api';
|
||||||
import { getAddedAndRemoved } from '../../../util/lists';
|
|
||||||
import OrganizationForm from '../shared/OrganizationForm';
|
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 }) {
|
function OrganizationEdit({ organization }) {
|
||||||
const detailsUrl = `/organizations/${organization.id}/details`;
|
const detailsUrl = `/organizations/${organization.id}/details`;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -17,43 +22,35 @@ function OrganizationEdit({ organization }) {
|
|||||||
groupsToDisassociate
|
groupsToDisassociate
|
||||||
) => {
|
) => {
|
||||||
try {
|
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, {
|
await OrganizationsAPI.update(organization.id, {
|
||||||
...values,
|
...values,
|
||||||
default_environment: values.default_environment?.id || null,
|
default_environment: values.default_environment?.id || null,
|
||||||
});
|
});
|
||||||
await Promise.all(
|
await OrganizationsAPI.orderInstanceGroups(
|
||||||
groupsToAssociate
|
organization.id,
|
||||||
.map(id =>
|
groupsToAssociate,
|
||||||
OrganizationsAPI.associateInstanceGroup(organization.id, id)
|
|
||||||
)
|
|
||||||
.concat(
|
|
||||||
addedCredentialIds.map(id =>
|
|
||||||
OrganizationsAPI.associateGalaxyCredential(organization.id, id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await Promise.all(
|
|
||||||
groupsToDisassociate
|
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);
|
history.push(detailsUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFormError(error);
|
setFormError(error);
|
||||||
|
|||||||
@@ -54,18 +54,34 @@ describe('<OrganizationEdit />', () => {
|
|||||||
name: 'new name',
|
name: 'new name',
|
||||||
description: 'new description',
|
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 () => {
|
await act(async () => {
|
||||||
wrapper.find('OrganizationForm').invoke('onSubmit')(
|
wrapper.find('OrganizationForm').invoke('onSubmit')(
|
||||||
updatedOrgData,
|
updatedOrgData,
|
||||||
[3, 4],
|
newInstanceGroups,
|
||||||
[2]
|
oldInstanceGroups
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 3);
|
expect(OrganizationsAPI.orderInstanceGroups).toHaveBeenCalledWith(
|
||||||
expect(OrganizationsAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 4);
|
mockData.id,
|
||||||
expect(OrganizationsAPI.disassociateInstanceGroup).toHaveBeenCalledWith(
|
newInstanceGroups,
|
||||||
1,
|
oldInstanceGroups
|
||||||
2
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t, Trans } from '@lingui/macro';
|
||||||
import { Form } from '@patternfly/react-core';
|
import { Form } from '@patternfly/react-core';
|
||||||
|
|
||||||
import { OrganizationsAPI } from '../../../api';
|
import { OrganizationsAPI } from '../../../api';
|
||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
InstanceGroupsLookup,
|
InstanceGroupsLookup,
|
||||||
ExecutionEnvironmentLookup,
|
ExecutionEnvironmentLookup,
|
||||||
} from '../../../components/Lookup';
|
} from '../../../components/Lookup';
|
||||||
import { getAddedAndRemoved } from '../../../util/lists';
|
|
||||||
import { required, minMaxValue } from '../../../util/validators';
|
import { required, minMaxValue } from '../../../util/validators';
|
||||||
import { FormColumnLayout } from '../../../components/FormLayout';
|
import { FormColumnLayout } from '../../../components/FormLayout';
|
||||||
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
|
import CredentialLookup from '../../../components/Lookup/CredentialLookup';
|
||||||
@@ -106,7 +105,20 @@ function OrganizationFormFields({
|
|||||||
onChange={handleCredentialUpdate}
|
onChange={handleCredentialUpdate}
|
||||||
value={galaxyCredentialsField.value}
|
value={galaxyCredentialsField.value}
|
||||||
multiple
|
multiple
|
||||||
|
isSelectedDraggable
|
||||||
fieldName="galaxy_credentials"
|
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 handleSubmit = values => {
|
||||||
const { added, removed } = getAddedAndRemoved(
|
|
||||||
initialInstanceGroups,
|
|
||||||
instanceGroups
|
|
||||||
);
|
|
||||||
const addedIds = added.map(({ id }) => id);
|
|
||||||
const removedIds = removed.map(({ id }) => id);
|
|
||||||
if (
|
if (
|
||||||
typeof values.max_hosts !== 'number' ||
|
typeof values.max_hosts !== 'number' ||
|
||||||
values.max_hosts === 'undefined'
|
values.max_hosts === 'undefined'
|
||||||
) {
|
) {
|
||||||
values.max_hosts = 0;
|
values.max_hosts = 0;
|
||||||
}
|
}
|
||||||
onSubmit(values, addedIds, removedIds);
|
onSubmit(values, instanceGroups, initialInstanceGroups);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -258,7 +258,14 @@ describe('<OrganizationForm />', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
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 () => {
|
test('onSubmit does not get called if max_hosts value is out of range', async () => {
|
||||||
@@ -332,8 +339,8 @@ describe('<OrganizationForm />', () => {
|
|||||||
max_hosts: 0,
|
max_hosts: 0,
|
||||||
default_environment: null,
|
default_environment: null,
|
||||||
},
|
},
|
||||||
[],
|
mockInstanceGroups,
|
||||||
[]
|
mockInstanceGroups
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ function JobTemplateAdd() {
|
|||||||
return Promise.all([...associationPromises]);
|
return Promise.all([...associationPromises]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitInstanceGroups(templateId, addedGroups = []) {
|
async function submitInstanceGroups(templateId, addedGroups = []) {
|
||||||
const associatePromises = addedGroups.map(group =>
|
/* eslint-disable no-await-in-loop, no-restricted-syntax */
|
||||||
JobTemplatesAPI.associateInstanceGroup(templateId, group.id)
|
// Resolve Promises sequentially to maintain order and avoid race condition
|
||||||
);
|
for (const group of addedGroups) {
|
||||||
return Promise.all(associatePromises);
|
await JobTemplatesAPI.associateInstanceGroup(templateId, group.id);
|
||||||
|
}
|
||||||
|
/* eslint-enable no-await-in-loop, no-restricted-syntax */
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitCredentials(templateId, credentials = []) {
|
function submitCredentials(templateId, credentials = []) {
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ describe('<JobTemplateAdd />', () => {
|
|||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<JobTemplateAdd />);
|
wrapper = mountWithContexts(<JobTemplateAdd />);
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
expect(wrapper.find('input#template-description').text()).toBe(
|
expect(wrapper.find('input#template-description').text()).toBe(
|
||||||
defaultProps.description
|
defaultProps.description
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import JobTemplateForm from '../shared/JobTemplateForm';
|
|||||||
import ContentLoading from '../../../components/ContentLoading';
|
import ContentLoading from '../../../components/ContentLoading';
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
|
|
||||||
function JobTemplateEdit({ template }) {
|
function JobTemplateEdit({ template, reloadTemplate }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [formSubmitError, setFormSubmitError] = useState(null);
|
const [formSubmitError, setFormSubmitError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -18,11 +18,7 @@ function JobTemplateEdit({ template }) {
|
|||||||
|
|
||||||
const detailsUrl = `/templates/${template.type}/${template.id}/details`;
|
const detailsUrl = `/templates/${template.type}/${template.id}/details`;
|
||||||
|
|
||||||
const {
|
const { request: fetchProject, error: fetchProjectError } = useRequest(
|
||||||
request: fetchProject,
|
|
||||||
error: fetchProjectError,
|
|
||||||
isLoading: projectLoading,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
await ProjectsAPI.readDetail(template.project);
|
await ProjectsAPI.readDetail(template.project);
|
||||||
}, [template.project])
|
}, [template.project])
|
||||||
@@ -65,9 +61,14 @@ function JobTemplateEdit({ template }) {
|
|||||||
await JobTemplatesAPI.update(template.id, remainingValues);
|
await JobTemplatesAPI.update(template.id, remainingValues);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
submitLabels(labels, template?.organization),
|
submitLabels(labels, template?.organization),
|
||||||
submitInstanceGroups(instanceGroups, initialInstanceGroups),
|
|
||||||
submitCredentials(credentials),
|
submitCredentials(credentials),
|
||||||
|
JobTemplatesAPI.orderInstanceGroups(
|
||||||
|
template.id,
|
||||||
|
instanceGroups,
|
||||||
|
initialInstanceGroups
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
reloadTemplate();
|
||||||
history.push(detailsUrl);
|
history.push(detailsUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFormSubmitError(error);
|
setFormSubmitError(error);
|
||||||
@@ -96,17 +97,6 @@ function JobTemplateEdit({ template }) {
|
|||||||
return results;
|
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 submitCredentials = async newCredentials => {
|
||||||
const { added, removed } = getAddedAndRemoved(
|
const { added, removed } = getAddedAndRemoved(
|
||||||
template.summary_fields.credentials,
|
template.summary_fields.credentials,
|
||||||
@@ -130,7 +120,7 @@ function JobTemplateEdit({ template }) {
|
|||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
return <Redirect to={detailsUrl} />;
|
return <Redirect to={detailsUrl} />;
|
||||||
}
|
}
|
||||||
if (isLoading || projectLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,8 +287,8 @@ describe('<JobTemplateEdit />', () => {
|
|||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobTemplateEdit template={mockJobTemplate} />
|
<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"]').length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('FormGroup[label="Host Config Key"]').prop('isRequired')
|
wrapper.find('FormGroup[label="Host Config Key"]').prop('isRequired')
|
||||||
@@ -301,8 +301,9 @@ describe('<JobTemplateEdit />', () => {
|
|||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobTemplateEdit template={mockJobTemplate} />
|
<JobTemplateEdit template={mockJobTemplate} />
|
||||||
);
|
);
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
const updatedTemplateData = {
|
const updatedTemplateData = {
|
||||||
job_type: 'check',
|
job_type: 'check',
|
||||||
name: 'new name',
|
name: 'new name',
|
||||||
|
|||||||
@@ -72,16 +72,7 @@ function Template({ setBreadcrumb }) {
|
|||||||
surveyConfiguration = survey;
|
surveyConfiguration = survey;
|
||||||
}
|
}
|
||||||
if (data.summary_fields.credentials) {
|
if (data.summary_fields.credentials) {
|
||||||
const params = {
|
data.summary_fields.credentials = defaultCredentials;
|
||||||
page: 1,
|
|
||||||
page_size: 200,
|
|
||||||
order_by: 'name',
|
|
||||||
};
|
|
||||||
const {
|
|
||||||
data: { results },
|
|
||||||
} = await JobTemplatesAPI.readCredentials(data.id, params);
|
|
||||||
|
|
||||||
data.summary_fields.credentials = results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions.data.actions.PUT) {
|
if (actions.data.actions.PUT) {
|
||||||
@@ -106,7 +97,7 @@ function Template({ setBreadcrumb }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplateAndRoles();
|
loadTemplateAndRoles();
|
||||||
}, [loadTemplateAndRoles, location.pathname]);
|
}, [loadTemplateAndRoles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (template) {
|
if (template) {
|
||||||
@@ -214,7 +205,10 @@ function Template({ setBreadcrumb }) {
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route key="edit" path="/templates/:templateType/:id/edit">
|
<Route key="edit" path="/templates/:templateType/:id/edit">
|
||||||
<JobTemplateEdit template={template} />
|
<JobTemplateEdit
|
||||||
|
template={template}
|
||||||
|
reloadTemplate={loadTemplateAndRoles}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route key="access" path="/templates/:templateType/:id/access">
|
<Route key="access" path="/templates/:templateType/:id/access">
|
||||||
<ResourceAccessList
|
<ResourceAccessList
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function JobTemplateForm({
|
|||||||
setFieldTouched,
|
setFieldTouched,
|
||||||
submitError,
|
submitError,
|
||||||
validateField,
|
validateField,
|
||||||
isOverrideDisabledLookup,
|
isOverrideDisabledLookup, // TODO: this is a confusing variable name
|
||||||
}) {
|
}) {
|
||||||
const [contentError, setContentError] = useState(false);
|
const [contentError, setContentError] = useState(false);
|
||||||
const [allowCallbacks, setAllowCallbacks] = useState(
|
const [allowCallbacks, setAllowCallbacks] = useState(
|
||||||
@@ -123,7 +123,10 @@ function JobTemplateForm({
|
|||||||
setFieldValue('instanceGroups', [...data.results]);
|
setFieldValue('instanceGroups', [...data.results]);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [setFieldValue, template])
|
}, [setFieldValue, template]),
|
||||||
|
{
|
||||||
|
isLoading: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ describe('<JobTemplateForm />', () => {
|
|||||||
|
|
||||||
test('should update form values on input changes', async () => {
|
test('should update form values on input changes', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(() => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobTemplateForm
|
<JobTemplateForm
|
||||||
template={mockData}
|
template={mockData}
|
||||||
@@ -193,8 +193,9 @@ describe('<JobTemplateForm />', () => {
|
|||||||
handleCancel={jest.fn()}
|
handleCancel={jest.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
await waitForElement(wrapper, 'EmptyStateBody', el => el.length === 0);
|
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('input#template-name').simulate('change', {
|
wrapper.find('input#template-name').simulate('change', {
|
||||||
target: { value: 'new foo', name: 'name' },
|
target: { value: 'new foo', name: 'name' },
|
||||||
@@ -308,6 +309,8 @@ describe('<JobTemplateForm />', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
|
wrapper.find('Checkbox[aria-label="Enable Webhook"]').invoke('onChange')(
|
||||||
true,
|
true,
|
||||||
@@ -392,6 +395,8 @@ describe('<JobTemplateForm />', () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('TextInputBase#template-webhook_key').prop('value')
|
wrapper.find('TextInputBase#template-webhook_key').prop('value')
|
||||||
).toBe('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE.');
|
).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')
|
wrapper.find('Button[aria-label="Update webhook key"]').prop('isDisabled')
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should call handleSubmit when Submit button is clicked', async () => {
|
test('should call handleSubmit when Submit button is clicked', async () => {
|
||||||
const handleSubmit = jest.fn();
|
const handleSubmit = jest.fn();
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import useIsMounted from './useIsMounted';
|
|||||||
export default function useRequest(makeRequest, initialValue) {
|
export default function useRequest(makeRequest, initialValue) {
|
||||||
const [result, setResult] = useState(initialValue);
|
const [result, setResult] = useState(initialValue);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(initialValue?.isLoading || false);
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -47,11 +47,16 @@ class HasStatus(object):
|
|||||||
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
|
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
|
||||||
|
|
||||||
def failure_output_details(self):
|
def failure_output_details(self):
|
||||||
|
msg = ''
|
||||||
if getattr(self, 'result_stdout', ''):
|
if getattr(self, 'result_stdout', ''):
|
||||||
output = bytes_to_str(self.result_stdout)
|
output = bytes_to_str(self.result_stdout)
|
||||||
if output:
|
if output:
|
||||||
return '\nstdout:\n{}'.format(output)
|
msg = '\nstdout:\n{}'.format(output)
|
||||||
return ''
|
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):
|
def assert_status(self, status_list, msg=None):
|
||||||
if isinstance(status_list, str):
|
if isinstance(status_list, str):
|
||||||
@@ -65,8 +70,6 @@ class HasStatus(object):
|
|||||||
else:
|
else:
|
||||||
msg += '\n'
|
msg += '\n'
|
||||||
msg += '{0}-{1} has status of {2}, which is not in {3}.'.format(self.type.title(), self.id, self.status, status_list)
|
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', ''):
|
if getattr(self, 'execution_environment', ''):
|
||||||
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
|
msg += '\nexecution_environment: {}'.format(bytes_to_str(self.execution_environment))
|
||||||
if getattr(self, 'related', False):
|
if getattr(self, 'related', False):
|
||||||
@@ -75,19 +78,17 @@ class HasStatus(object):
|
|||||||
msg += f'\nee_credential: {ee.credential}'
|
msg += f'\nee_credential: {ee.credential}'
|
||||||
msg += f'\nee_pull_option: {ee.pull}'
|
msg += f'\nee_pull_option: {ee.pull}'
|
||||||
msg += f'\nee_summary_fields: {ee.summary_fields}'
|
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()
|
msg += self.failure_output_details()
|
||||||
|
|
||||||
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
|
if getattr(self, 'job_explanation', '').startswith('Previous Task Failed'):
|
||||||
try:
|
try:
|
||||||
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
|
data = json.loads(self.job_explanation.replace('Previous Task Failed: ', ''))
|
||||||
dep_output = self.connection.get(
|
dependency = self.walk('/api/v2/{0}s/{1}/'.format(data['job_type'], data['job_id']))
|
||||||
'{0}/api/v2/{1}s/{2}/stdout/'.format(self.endpoint.split('/api')[0], data['job_type'], data['job_id']),
|
if hasattr(dependency, 'failure_output_details'):
|
||||||
query_parameters=dict(format='txt_download'),
|
msg += '\nDependency output:\n{}'.format(dependency.failure_output_details())
|
||||||
).content
|
else:
|
||||||
msg += '\nDependency output:\n{}'.format(bytes_to_str(dep_output))
|
msg += '\nDependency info:\n{}'.format(dependency)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg += '\nFailed to obtain dependency stdout: {}'.format(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 \
|
$ ansible-playbook ansible/instantiate-awx-deployment.yml \
|
||||||
-e development_mode=yes \
|
-e development_mode=yes \
|
||||||
-e tower_image=quay.io/awx/awx_kube_devel \
|
-e image=quay.io/awx/awx_kube_devel \
|
||||||
-e tower_image_version=devel \
|
-e image_version=devel \
|
||||||
-e tower_image_pull_policy=Always \
|
-e image_pull_policy=Always \
|
||||||
-e tower_ingress_type=ingress
|
-e service_type=nodeport \
|
||||||
|
-e namespace=default
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom AWX Development Image for Kubernetes
|
### Custom AWX Development Image for Kubernetes
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
aiohttp
|
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
|
ansiconv==1.0.0 # UPGRADE BLOCKER: from 2013, consider replacing instead of upgrading
|
||||||
asciichartpy
|
asciichartpy
|
||||||
autobahn>=20.12.3 # CVE-2020-35678
|
autobahn>=20.12.3 # CVE-2020-35678
|
||||||
@@ -45,7 +45,7 @@ python3-saml
|
|||||||
python-dsv-sdk
|
python-dsv-sdk
|
||||||
python-ldap>=3.3.1 # https://github.com/python-ldap/python-ldap/issues/270
|
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
|
pyyaml>=5.4.1 # minimum to fix https://github.com/yaml/pyyaml/issues/478
|
||||||
receptorctl
|
receptorctl==1.0.0.0rc1
|
||||||
schedule==0.6.0
|
schedule==0.6.0
|
||||||
social-auth-core==3.3.1 # see UPGRADE BLOCKERs
|
social-auth-core==3.3.1 # see UPGRADE BLOCKERs
|
||||||
social-auth-app-django==3.1.0 # 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
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
aioredis==1.3.1
|
aioredis==1.3.1
|
||||||
# via channels-redis
|
# via channels-redis
|
||||||
ansible-runner==2.0.0.0b1
|
ansible-runner==2.0.0.0rc3
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
ansiconv==1.0.0
|
ansiconv==1.0.0
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
@@ -47,12 +47,12 @@ cachetools==4.0.0
|
|||||||
# requests
|
# requests
|
||||||
cffi==1.14.0
|
cffi==1.14.0
|
||||||
# via cryptography
|
# via cryptography
|
||||||
channels-redis==3.1.0
|
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
|
||||||
channels==2.4.0
|
channels==2.4.0
|
||||||
# via
|
# via
|
||||||
# -r /awx_devel/requirements/requirements.in
|
# -r /awx_devel/requirements/requirements.in
|
||||||
# channels-redis
|
# channels-redis
|
||||||
|
channels-redis==3.1.0
|
||||||
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
chardet==3.0.4
|
chardet==3.0.4
|
||||||
# via
|
# via
|
||||||
# aiohttp
|
# aiohttp
|
||||||
@@ -85,6 +85,19 @@ dictdiffer==0.8.1
|
|||||||
# via openshift
|
# via openshift
|
||||||
distro==1.5.0
|
distro==1.5.0
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# 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
|
django-auth-ldap==2.1.0
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
django-cors-headers==3.7.0
|
django-cors-headers==3.7.0
|
||||||
@@ -115,23 +128,10 @@ django-split-settings==1.0.0
|
|||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
django-taggit==1.2.0
|
django-taggit==1.2.0
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# 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
|
djangorestframework==3.12.1
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
|
djangorestframework-yaml==1.0.3
|
||||||
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
docutils==0.16
|
docutils==0.16
|
||||||
# via python-daemon
|
# via python-daemon
|
||||||
future==0.16.0
|
future==0.16.0
|
||||||
@@ -237,17 +237,17 @@ psycopg2==2.8.4
|
|||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
ptyprocess==0.6.0
|
ptyprocess==0.6.0
|
||||||
# via pexpect
|
# via pexpect
|
||||||
pyasn1-modules==0.2.8
|
|
||||||
# via
|
|
||||||
# google-auth
|
|
||||||
# python-ldap
|
|
||||||
# service-identity
|
|
||||||
pyasn1==0.4.8
|
pyasn1==0.4.8
|
||||||
# via
|
# via
|
||||||
# pyasn1-modules
|
# pyasn1-modules
|
||||||
# python-ldap
|
# python-ldap
|
||||||
# rsa
|
# rsa
|
||||||
# service-identity
|
# service-identity
|
||||||
|
pyasn1-modules==0.2.8
|
||||||
|
# via
|
||||||
|
# google-auth
|
||||||
|
# python-ldap
|
||||||
|
# service-identity
|
||||||
pycparser==2.20
|
pycparser==2.20
|
||||||
# via cffi
|
# via cffi
|
||||||
pygerduty==0.38.2
|
pygerduty==0.38.2
|
||||||
@@ -299,17 +299,12 @@ pyyaml==5.4.1
|
|||||||
# djangorestframework-yaml
|
# djangorestframework-yaml
|
||||||
# kubernetes
|
# kubernetes
|
||||||
# receptorctl
|
# receptorctl
|
||||||
receptorctl==1.0.0.0a2
|
receptorctl==1.0.0.0rc1
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
redis==3.4.1
|
redis==3.4.1
|
||||||
# via
|
# via
|
||||||
# -r /awx_devel/requirements/requirements.in
|
# -r /awx_devel/requirements/requirements.in
|
||||||
# django-redis
|
# django-redis
|
||||||
requests-oauthlib==1.3.0
|
|
||||||
# via
|
|
||||||
# kubernetes
|
|
||||||
# msrest
|
|
||||||
# social-auth-core
|
|
||||||
requests==2.23.0
|
requests==2.23.0
|
||||||
# via
|
# via
|
||||||
# -r /awx_devel/requirements/requirements.in
|
# -r /awx_devel/requirements/requirements.in
|
||||||
@@ -323,12 +318,17 @@ requests==2.23.0
|
|||||||
# slackclient
|
# slackclient
|
||||||
# social-auth-core
|
# social-auth-core
|
||||||
# twilio
|
# twilio
|
||||||
|
requests-oauthlib==1.3.0
|
||||||
|
# via
|
||||||
|
# kubernetes
|
||||||
|
# msrest
|
||||||
|
# social-auth-core
|
||||||
rsa==4.0
|
rsa==4.0
|
||||||
# via google-auth
|
# via google-auth
|
||||||
ruamel.yaml.clib==0.2.0
|
|
||||||
# via ruamel.yaml
|
|
||||||
ruamel.yaml==0.16.10
|
ruamel.yaml==0.16.10
|
||||||
# via openshift
|
# via openshift
|
||||||
|
ruamel.yaml.clib==0.2.0
|
||||||
|
# via ruamel.yaml
|
||||||
schedule==0.6.0
|
schedule==0.6.0
|
||||||
# via -r /awx_devel/requirements/requirements.in
|
# via -r /awx_devel/requirements/requirements.in
|
||||||
service-identity==18.1.0
|
service-identity==18.1.0
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -154,7 +154,7 @@ setup(
|
|||||||
"tools/scripts/ansible-tower-setup",
|
"tools/scripts/ansible-tower-setup",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
("%s" % sosconfig, ["tools/sosreport/tower.py"]),
|
("%s" % sosconfig, ["tools/sosreport/controller.py"]),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
options={
|
options={
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ readonly CMDNAME=$(basename "$0")
|
|||||||
|
|
||||||
readonly MIN_SLEEP=0.5
|
readonly MIN_SLEEP=0.5
|
||||||
readonly MAX_SLEEP=30
|
readonly MAX_SLEEP=30
|
||||||
readonly ATTEMPTS=10
|
readonly ATTEMPTS=30
|
||||||
readonly TIMEOUT=60
|
readonly TIMEOUT=60
|
||||||
|
|
||||||
log_message() { echo "[${CMDNAME}]" "$@" >&2; }
|
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)
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user