mirror of
https://github.com/ansible/awx.git
synced 2026-03-26 13:25:02 -02:30
@@ -1,2 +1 @@
|
|||||||
.git
|
|
||||||
awx/ui/node_modules
|
awx/ui/node_modules
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ If you're not using Docker for Mac, or Docker for Windows, you may need, or choo
|
|||||||
|
|
||||||
#### Frontend Development
|
#### Frontend Development
|
||||||
|
|
||||||
See [the ui development documentation](awx/ui/README.md).
|
See [the ui development documentation](awx/ui_next/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
|
||||||
### Build the environment
|
### Build the environment
|
||||||
@@ -158,7 +158,7 @@ $ docker ps
|
|||||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||||
44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e
|
44251b476f98 gcr.io/ansible-tower-engineering/awx_devel:devel "/entrypoint.sh /bin…" 27 seconds ago Up 23 seconds 0.0.0.0:6899->6899/tcp, 0.0.0.0:7899-7999->7899-7999/tcp, 0.0.0.0:8013->8013/tcp, 0.0.0.0:8043->8043/tcp, 0.0.0.0:8080->8080/tcp, 22/tcp, 0.0.0.0:8888->8888/tcp tools_awx_run_9e820694d57e
|
||||||
40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds
|
40de380e3c2e redis:latest "docker-entrypoint.s…" 28 seconds ago Up 26 seconds
|
||||||
b66a506d3007 postgres:10 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
|
b66a506d3007 postgres:12 "docker-entrypoint.s…" 28 seconds ago Up 26 seconds 0.0.0.0:5432->5432/tcp tools_postgres_1
|
||||||
```
|
```
|
||||||
**NOTE**
|
**NOTE**
|
||||||
|
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -19,7 +19,8 @@ PYCURL_SSL_LIBRARY ?= openssl
|
|||||||
COMPOSE_TAG ?= $(GIT_BRANCH)
|
COMPOSE_TAG ?= $(GIT_BRANCH)
|
||||||
COMPOSE_HOST ?= $(shell hostname)
|
COMPOSE_HOST ?= $(shell hostname)
|
||||||
|
|
||||||
VENV_BASE ?= /venv
|
VENV_BASE ?= /var/lib/awx/venv/
|
||||||
|
COLLECTION_BASE ?= /var/lib/awx/vendor/awx_ansible_collections
|
||||||
SCL_PREFIX ?=
|
SCL_PREFIX ?=
|
||||||
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
||||||
|
|
||||||
@@ -270,7 +271,7 @@ uwsgi: collectstatic
|
|||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
fi; \
|
||||||
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
|
uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/var/lib/awx/venv/awx --chdir=/awx_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --lazy-apps --logformat "%(addr) %(method) %(uri) - %(proto) %(status)" --hook-accepting1="exec:supervisorctl restart tower-processes:awx-dispatcher tower-processes:awx-receiver"
|
||||||
|
|
||||||
daphne:
|
daphne:
|
||||||
@if [ "$(VENV_BASE)" ]; then \
|
@if [ "$(VENV_BASE)" ]; then \
|
||||||
@@ -340,7 +341,7 @@ check: flake8 pep8 # pyflakes pylint
|
|||||||
|
|
||||||
awx-link:
|
awx-link:
|
||||||
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev
|
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev
|
||||||
cp -f /tmp/awx.egg-link /venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
|
cp -f /tmp/awx.egg-link /var/lib/awx/venv/awx/lib/python$(PYTHON_VERSION)/site-packages/awx.egg-link
|
||||||
|
|
||||||
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests
|
||||||
|
|
||||||
@@ -618,7 +619,10 @@ clean-elk:
|
|||||||
docker rm tools_kibana_1
|
docker rm tools_kibana_1
|
||||||
|
|
||||||
psql-container:
|
psql-container:
|
||||||
docker run -it --net tools_default --rm postgres:10 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
|
docker run -it --net tools_default --rm postgres:12 sh -c 'exec psql -h "postgres" -p "5432" -U postgres'
|
||||||
|
|
||||||
VERSION:
|
VERSION:
|
||||||
@echo "awx: $(VERSION)"
|
@echo "awx: $(VERSION)"
|
||||||
|
|
||||||
|
Dockerfile: installer/roles/image_build/templates/Dockerfile.j2
|
||||||
|
ansible localhost -m template -a "src=installer/roles/image_build/templates/Dockerfile.j2 dest=Dockerfile"
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
[](https://ansible.softwarefactory-project.io/zuul/status)
|
[](https://ansible.softwarefactory-project.io/zuul/status)
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
|
|
||||||
|
|
||||||
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
|
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
|
||||||
|
|
||||||
To install AWX, please view the [Install guide](./INSTALL.md).
|
To install AWX, please view the [Install guide](./INSTALL.md).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import LazySettings
|
from django.conf import LazySettings
|
||||||
@@ -247,6 +248,7 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
# These values have to be stored via self.__dict__ in this way to get
|
# These values have to be stored via self.__dict__ in this way to get
|
||||||
# around the magic __setattr__ method on this class (which is used to
|
# around the magic __setattr__ method on this class (which is used to
|
||||||
# store API-assigned settings in the database).
|
# store API-assigned settings in the database).
|
||||||
|
self.__dict__['__forks__'] = {}
|
||||||
self.__dict__['default_settings'] = default_settings
|
self.__dict__['default_settings'] = default_settings
|
||||||
self.__dict__['_awx_conf_settings'] = self
|
self.__dict__['_awx_conf_settings'] = self
|
||||||
self.__dict__['_awx_conf_preload_expires'] = None
|
self.__dict__['_awx_conf_preload_expires'] = None
|
||||||
@@ -255,6 +257,26 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
self.__dict__['cache'] = EncryptedCacheProxy(cache, registry)
|
||||||
self.__dict__['registry'] = registry
|
self.__dict__['registry'] = registry
|
||||||
|
|
||||||
|
# record the current pid so we compare it post-fork for
|
||||||
|
# processes like the dispatcher and callback receiver
|
||||||
|
self.__dict__['pid'] = os.getpid()
|
||||||
|
|
||||||
|
def __clean_on_fork__(self):
|
||||||
|
pid = os.getpid()
|
||||||
|
# if the current pid does *not* match the value on self, it means
|
||||||
|
# that value was copied on fork, and we're now in a *forked* process;
|
||||||
|
# the *first* time we enter this code path (on setting access),
|
||||||
|
# forcibly close DB/cache sockets and set a marker so we don't run
|
||||||
|
# this code again _in this process_
|
||||||
|
#
|
||||||
|
if pid != self.__dict__['pid'] and pid not in self.__dict__['__forks__']:
|
||||||
|
self.__dict__['__forks__'][pid] = True
|
||||||
|
# It's important to close these post-fork, because we
|
||||||
|
# don't want the forked processes to inherit the open sockets
|
||||||
|
# for the DB and cache connections (that way lies race conditions)
|
||||||
|
connection.close()
|
||||||
|
django_cache.close()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def all_supported_settings(self):
|
def all_supported_settings(self):
|
||||||
return self.registry.get_registered_settings()
|
return self.registry.get_registered_settings()
|
||||||
@@ -330,6 +352,7 @@ class SettingsWrapper(UserSettingsHolder):
|
|||||||
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
|
self.cache.set_many(settings_to_cache, timeout=SETTING_CACHE_TIMEOUT)
|
||||||
|
|
||||||
def _get_local(self, name, validate=True):
|
def _get_local(self, name, validate=True):
|
||||||
|
self.__clean_on_fork__()
|
||||||
self._preload_cache()
|
self._preload_cache()
|
||||||
cache_key = Setting.get_cache_key(name)
|
cache_key = Setting.get_cache_key(name)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3354,6 +3354,15 @@ msgid ""
|
|||||||
"common scenarios."
|
"common scenarios."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:824
|
||||||
|
msgid "Region Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:826
|
||||||
|
msgid ""
|
||||||
|
"For some cloud providers, like OVH, region must be specified."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:824
|
#: awx/main/models/credential/__init__.py:824
|
||||||
#: awx/main/models/credential/__init__.py:1131
|
#: awx/main/models/credential/__init__.py:1131
|
||||||
#: awx/main/models/credential/__init__.py:1166
|
#: awx/main/models/credential/__init__.py:1166
|
||||||
|
|||||||
@@ -3354,6 +3354,15 @@ msgid ""
|
|||||||
"common scenarios."
|
"common scenarios."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:824
|
||||||
|
msgid "Region Name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:826
|
||||||
|
msgid ""
|
||||||
|
"For some cloud providers, like OVH, region must be specified."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:824
|
#: awx/main/models/credential/__init__.py:824
|
||||||
#: awx/main/models/credential/__init__.py:1131
|
#: awx/main/models/credential/__init__.py:1131
|
||||||
#: awx/main/models/credential/__init__.py:1166
|
#: awx/main/models/credential/__init__.py:1166
|
||||||
|
|||||||
@@ -3294,6 +3294,16 @@ msgid ""
|
|||||||
"common scenarios."
|
"common scenarios."
|
||||||
msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL d’authentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants."
|
msgstr "Les domaines OpenStack définissent les limites administratives. Ils sont nécessaires uniquement pour les URL d’authentification Keystone v3. Voir la documentation Ansible Tower pour les scénarios courants."
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:824
|
||||||
|
msgid "Region Name"
|
||||||
|
msgstr "Nom de la region"
|
||||||
|
|
||||||
|
#: awx/main/models/credential/__init__.py:826
|
||||||
|
msgid ""
|
||||||
|
"For some cloud providers, like OVH, region must be specified."
|
||||||
|
msgstr ""
|
||||||
|
"Chez certains fournisseurs, comme OVH, vous devez spécifier le nom de la région"
|
||||||
|
|
||||||
#: awx/main/models/credential/__init__.py:812
|
#: awx/main/models/credential/__init__.py:812
|
||||||
#: awx/main/models/credential/__init__.py:1110
|
#: awx/main/models/credential/__init__.py:1110
|
||||||
#: awx/main/models/credential/__init__.py:1144
|
#: awx/main/models/credential/__init__.py:1144
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import yaml
|
import yaml
|
||||||
|
import datetime
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import ansible_runner
|
import ansible_runner
|
||||||
@@ -123,6 +124,7 @@ class IsolatedManager(object):
|
|||||||
dir=private_data_dir
|
dir=private_data_dir
|
||||||
)
|
)
|
||||||
params = self.runner_params.copy()
|
params = self.runner_params.copy()
|
||||||
|
params.get('envvars', dict())['ANSIBLE_CALLBACK_WHITELIST'] = 'profile_tasks'
|
||||||
params['playbook'] = playbook
|
params['playbook'] = playbook
|
||||||
params['private_data_dir'] = iso_dir
|
params['private_data_dir'] = iso_dir
|
||||||
if idle_timeout:
|
if idle_timeout:
|
||||||
@@ -168,7 +170,8 @@ class IsolatedManager(object):
|
|||||||
extravars = {
|
extravars = {
|
||||||
'src': self.private_data_dir,
|
'src': self.private_data_dir,
|
||||||
'dest': settings.AWX_PROOT_BASE_PATH,
|
'dest': settings.AWX_PROOT_BASE_PATH,
|
||||||
'ident': self.ident
|
'ident': self.ident,
|
||||||
|
'job_id': self.instance.id,
|
||||||
}
|
}
|
||||||
if playbook:
|
if playbook:
|
||||||
extravars['playbook'] = playbook
|
extravars['playbook'] = playbook
|
||||||
@@ -204,7 +207,10 @@ class IsolatedManager(object):
|
|||||||
:param interval: an interval (in seconds) to wait between status polls
|
:param interval: an interval (in seconds) to wait between status polls
|
||||||
"""
|
"""
|
||||||
interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL
|
interval = interval if interval is not None else settings.AWX_ISOLATED_CHECK_INTERVAL
|
||||||
extravars = {'src': self.private_data_dir}
|
extravars = {
|
||||||
|
'src': self.private_data_dir,
|
||||||
|
'job_id': self.instance.id
|
||||||
|
}
|
||||||
status = 'failed'
|
status = 'failed'
|
||||||
rc = None
|
rc = None
|
||||||
last_check = time.time()
|
last_check = time.time()
|
||||||
@@ -220,9 +226,13 @@ class IsolatedManager(object):
|
|||||||
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id))
|
logger.warning('Isolated job {} was manually canceled.'.format(self.instance.id))
|
||||||
|
|
||||||
logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id))
|
logger.debug('Checking on isolated job {} with `check_isolated.yml`.'.format(self.instance.id))
|
||||||
|
time_start = datetime.datetime.now()
|
||||||
runner_obj = self.run_management_playbook('check_isolated.yml',
|
runner_obj = self.run_management_playbook('check_isolated.yml',
|
||||||
self.private_data_dir,
|
self.private_data_dir,
|
||||||
extravars=extravars)
|
extravars=extravars)
|
||||||
|
time_end = datetime.datetime.now()
|
||||||
|
time_diff = time_end - time_start
|
||||||
|
logger.debug('Finished checking on isolated job {} with `check_isolated.yml` took {} seconds.'.format(self.instance.id, time_diff.total_seconds()))
|
||||||
status, rc = runner_obj.status, runner_obj.rc
|
status, rc = runner_obj.status, runner_obj.rc
|
||||||
|
|
||||||
if self.check_callback is not None and not self.captured_command_artifact:
|
if self.check_callback is not None and not self.captured_command_artifact:
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ class AnsibleInventoryLoader(object):
|
|||||||
# NOTE: why do we add "python" to the start of these args?
|
# NOTE: why do we add "python" to the start of these args?
|
||||||
# the script that runs ansible-inventory specifies a python interpreter
|
# the script that runs ansible-inventory specifies a python interpreter
|
||||||
# that makes no sense in light of the fact that we put all the dependencies
|
# that makes no sense in light of the fact that we put all the dependencies
|
||||||
# inside of /venv/ansible, so we override the specified interpreter
|
# inside of /var/lib/awx/venv/ansible, so we override the specified interpreter
|
||||||
# https://github.com/ansible/ansible/issues/50714
|
# https://github.com/ansible/ansible/issues/50714
|
||||||
bargs = ['python', ansible_inventory_path, '-i', self.source]
|
bargs = ['python', ansible_inventory_path, '-i', self.source]
|
||||||
bargs.extend(['--playbook-dir', functioning_dir(self.source)])
|
bargs.extend(['--playbook-dir', functioning_dir(self.source)])
|
||||||
|
|||||||
@@ -819,6 +819,11 @@ ManagedCredentialType(
|
|||||||
'It is only needed for Keystone v3 authentication '
|
'It is only needed for Keystone v3 authentication '
|
||||||
'URLs. Refer to Ansible Tower documentation for '
|
'URLs. Refer to Ansible Tower documentation for '
|
||||||
'common scenarios.')
|
'common scenarios.')
|
||||||
|
}, {
|
||||||
|
'id': 'region',
|
||||||
|
'label': ugettext_noop('Region Name'),
|
||||||
|
'type': 'string',
|
||||||
|
'help_text': ugettext_noop('For some cloud providers, like OVH, region must be specified'),
|
||||||
}, {
|
}, {
|
||||||
'id': 'verify_ssl',
|
'id': 'verify_ssl',
|
||||||
'label': ugettext_noop('Verify SSL'),
|
'label': ugettext_noop('Verify SSL'),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ def _openstack_data(cred):
|
|||||||
if cred.has_input('domain'):
|
if cred.has_input('domain'):
|
||||||
openstack_auth['domain_name'] = cred.get_input('domain', default='')
|
openstack_auth['domain_name'] = cred.get_input('domain', default='')
|
||||||
verify_state = cred.get_input('verify_ssl', default=True)
|
verify_state = cred.get_input('verify_ssl', default=True)
|
||||||
|
|
||||||
openstack_data = {
|
openstack_data = {
|
||||||
'clouds': {
|
'clouds': {
|
||||||
'devstack': {
|
'devstack': {
|
||||||
@@ -90,6 +91,10 @@ def _openstack_data(cred):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cred.has_input('project_region_name'):
|
||||||
|
openstack_data['clouds']['devstack']['region_name'] = cred.get_input('project_region_name', default='')
|
||||||
|
|
||||||
return openstack_data
|
return openstack_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.core.mail.message import EmailMessage
|
|||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.utils.encoding import smart_str, force_text
|
from django.utils.encoding import smart_str, force_text
|
||||||
from jinja2 import sandbox
|
from jinja2 import sandbox, ChainableUndefined
|
||||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
|
from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
@@ -429,7 +429,7 @@ class JobNotificationMixin(object):
|
|||||||
raise RuntimeError("Define me")
|
raise RuntimeError("Define me")
|
||||||
|
|
||||||
def build_notification_message(self, nt, status):
|
def build_notification_message(self, nt, status):
|
||||||
env = sandbox.ImmutableSandboxedEnvironment()
|
env = sandbox.ImmutableSandboxedEnvironment(undefined=ChainableUndefined)
|
||||||
|
|
||||||
from awx.api.serializers import UnifiedJobSerializer
|
from awx.api.serializers import UnifiedJobSerializer
|
||||||
job_serialization = UnifiedJobSerializer(self).to_representation(self)
|
job_serialization = UnifiedJobSerializer(self).to_representation(self)
|
||||||
|
|||||||
@@ -378,6 +378,7 @@ def gather_analytics():
|
|||||||
|
|
||||||
from awx.conf.models import Setting
|
from awx.conf.models import Setting
|
||||||
from rest_framework.fields import DateTimeField
|
from rest_framework.fields import DateTimeField
|
||||||
|
from awx.main.signals import disable_activity_stream
|
||||||
if not settings.INSIGHTS_TRACKING_STATE:
|
if not settings.INSIGHTS_TRACKING_STATE:
|
||||||
return
|
return
|
||||||
if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD):
|
if not (settings.AUTOMATION_ANALYTICS_URL and settings.REDHAT_USERNAME and settings.REDHAT_PASSWORD):
|
||||||
@@ -414,7 +415,8 @@ def gather_analytics():
|
|||||||
if not _gather_and_ship(incremental_collectors, since=start, until=until):
|
if not _gather_and_ship(incremental_collectors, since=start, until=until):
|
||||||
break
|
break
|
||||||
start = until
|
start = until
|
||||||
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
|
with disable_activity_stream():
|
||||||
|
settings.AUTOMATION_ANALYTICS_LAST_GATHER = until
|
||||||
if subset:
|
if subset:
|
||||||
_gather_and_ship(subset, since=since, until=gather_time)
|
_gather_and_ship(subset, since=since, until=gather_time)
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def test_awx_virtualenv_from_settings(inventory, project, machine_credential):
|
|||||||
)
|
)
|
||||||
jt.credentials.add(machine_credential)
|
jt.credentials.add(machine_credential)
|
||||||
job = jt.create_unified_job()
|
job = jt.create_unified_job()
|
||||||
assert job.ansible_virtualenv_path == '/venv/ansible'
|
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/ansible'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -43,28 +43,28 @@ def test_awx_custom_virtualenv(inventory, project, machine_credential, organizat
|
|||||||
jt.credentials.add(machine_credential)
|
jt.credentials.add(machine_credential)
|
||||||
job = jt.create_unified_job()
|
job = jt.create_unified_job()
|
||||||
|
|
||||||
job.organization.custom_virtualenv = '/venv/fancy-org'
|
job.organization.custom_virtualenv = '/var/lib/awx/venv/fancy-org'
|
||||||
job.organization.save()
|
job.organization.save()
|
||||||
assert job.ansible_virtualenv_path == '/venv/fancy-org'
|
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-org'
|
||||||
|
|
||||||
job.project.custom_virtualenv = '/venv/fancy-proj'
|
job.project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
|
||||||
job.project.save()
|
job.project.save()
|
||||||
assert job.ansible_virtualenv_path == '/venv/fancy-proj'
|
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
|
||||||
|
|
||||||
job.job_template.custom_virtualenv = '/venv/fancy-jt'
|
job.job_template.custom_virtualenv = '/var/lib/awx/venv/fancy-jt'
|
||||||
job.job_template.save()
|
job.job_template.save()
|
||||||
assert job.ansible_virtualenv_path == '/venv/fancy-jt'
|
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-jt'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_awx_custom_virtualenv_without_jt(project):
|
def test_awx_custom_virtualenv_without_jt(project):
|
||||||
project.custom_virtualenv = '/venv/fancy-proj'
|
project.custom_virtualenv = '/var/lib/awx/venv/fancy-proj'
|
||||||
project.save()
|
project.save()
|
||||||
job = Job(project=project)
|
job = Job(project=project)
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
job = Job.objects.get(pk=job.id)
|
job = Job.objects.get(pk=job.id)
|
||||||
assert job.ansible_virtualenv_path == '/venv/fancy-proj'
|
assert job.ansible_virtualenv_path == '/var/lib/awx/venv/fancy-proj'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ def test_openstack_client_config_generation(mocker, source, expected, private_da
|
|||||||
'source_vars_dict': {},
|
'source_vars_dict': {},
|
||||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||||
'get_extra_credentials': lambda x: [],
|
'get_extra_credentials': lambda x: [],
|
||||||
'ansible_virtualenv_path': '/venv/foo'
|
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||||
})
|
})
|
||||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||||
cloud_credential = yaml.safe_load(
|
cloud_credential = yaml.safe_load(
|
||||||
@@ -224,6 +224,52 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
|
|||||||
'source_vars_dict': {},
|
'source_vars_dict': {},
|
||||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||||
'get_extra_credentials': lambda x: [],
|
'get_extra_credentials': lambda x: [],
|
||||||
|
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||||
|
})
|
||||||
|
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||||
|
cloud_credential = yaml.safe_load(
|
||||||
|
cloud_config.get('credentials')[credential]
|
||||||
|
)
|
||||||
|
assert cloud_credential['clouds'] == {
|
||||||
|
'devstack': {
|
||||||
|
'auth': {
|
||||||
|
'auth_url': 'https://keystone.openstack.example.org',
|
||||||
|
'password': 'secrete',
|
||||||
|
'project_name': 'demo-project',
|
||||||
|
'username': 'demo',
|
||||||
|
'domain_name': 'my-demo-domain',
|
||||||
|
'project_domain_name': 'project-domain',
|
||||||
|
},
|
||||||
|
'verify': expected,
|
||||||
|
'private': True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source,expected", [
|
||||||
|
(None, True), (False, False), (True, True)
|
||||||
|
])
|
||||||
|
def test_openstack_client_config_generation_with_project_region_name(mocker, source, expected, private_data_dir):
|
||||||
|
update = tasks.RunInventoryUpdate()
|
||||||
|
credential_type = CredentialType.defaults['openstack']()
|
||||||
|
inputs = {
|
||||||
|
'host': 'https://keystone.openstack.example.org',
|
||||||
|
'username': 'demo',
|
||||||
|
'password': 'secrete',
|
||||||
|
'project': 'demo-project',
|
||||||
|
'domain': 'my-demo-domain',
|
||||||
|
'project_domain_name': 'project-domain',
|
||||||
|
'project_region_name': 'region-name',
|
||||||
|
}
|
||||||
|
if source is not None:
|
||||||
|
inputs['verify_ssl'] = source
|
||||||
|
credential = Credential(pk=1, credential_type=credential_type, inputs=inputs)
|
||||||
|
|
||||||
|
inventory_update = mocker.Mock(**{
|
||||||
|
'source': 'openstack',
|
||||||
|
'source_vars_dict': {},
|
||||||
|
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||||
|
'get_extra_credentials': lambda x: [],
|
||||||
'ansible_virtualenv_path': '/venv/foo'
|
'ansible_virtualenv_path': '/venv/foo'
|
||||||
})
|
})
|
||||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||||
@@ -242,6 +288,7 @@ def test_openstack_client_config_generation_with_project_domain_name(mocker, sou
|
|||||||
},
|
},
|
||||||
'verify': expected,
|
'verify': expected,
|
||||||
'private': True,
|
'private': True,
|
||||||
|
'region_name': 'region-name',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +314,7 @@ def test_openstack_client_config_generation_with_private_source_vars(mocker, sou
|
|||||||
'source_vars_dict': {'private': source},
|
'source_vars_dict': {'private': source},
|
||||||
'get_cloud_credential': mocker.Mock(return_value=credential),
|
'get_cloud_credential': mocker.Mock(return_value=credential),
|
||||||
'get_extra_credentials': lambda x: [],
|
'get_extra_credentials': lambda x: [],
|
||||||
'ansible_virtualenv_path': '/venv/foo'
|
'ansible_virtualenv_path': '/var/lib/awx/venv/foo'
|
||||||
})
|
})
|
||||||
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
cloud_config = update.build_private_data(inventory_update, private_data_dir)
|
||||||
cloud_credential = yaml.load(
|
cloud_credential = yaml.load(
|
||||||
@@ -625,13 +672,13 @@ class TestGenericRun():
|
|||||||
|
|
||||||
def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir):
|
def test_invalid_custom_virtualenv(self, patch_Job, private_data_dir):
|
||||||
job = Job(project=Project(), inventory=Inventory())
|
job = Job(project=Project(), inventory=Inventory())
|
||||||
job.project.custom_virtualenv = '/venv/missing'
|
job.project.custom_virtualenv = '/var/lib/awx/venv/missing'
|
||||||
task = tasks.RunJob()
|
task = tasks.RunJob()
|
||||||
|
|
||||||
with pytest.raises(tasks.InvalidVirtualenvError) as e:
|
with pytest.raises(tasks.InvalidVirtualenvError) as e:
|
||||||
task.build_env(job, private_data_dir)
|
task.build_env(job, private_data_dir)
|
||||||
|
|
||||||
assert 'Invalid virtual environment selected: /venv/missing' == str(e.value)
|
assert 'Invalid virtual environment selected: /var/lib/awx/venv/missing' == str(e.value)
|
||||||
|
|
||||||
|
|
||||||
class TestAdhocRun(TestJobExecution):
|
class TestAdhocRun(TestJobExecution):
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
- ansible.posix
|
- ansible.posix
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
- name: "Output job the playbook is running for"
|
||||||
|
debug:
|
||||||
|
msg: "Checking on job {{ job_id }}"
|
||||||
|
|
||||||
- name: Determine if daemon process is alive.
|
- name: Determine if daemon process is alive.
|
||||||
shell: "ansible-runner is-alive {{src}}"
|
shell: "ansible-runner is-alive {{src}}"
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
- ansible.posix
|
- ansible.posix
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
- name: "Output job the playbook is running for"
|
||||||
|
debug:
|
||||||
|
msg: "Checking on job {{ job_id }}"
|
||||||
|
|
||||||
- name: synchronize job environment with isolated host
|
- name: synchronize job environment with isolated host
|
||||||
synchronize:
|
synchronize:
|
||||||
copy_links: true
|
copy_links: true
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ LOGIN_URL = '/api/login/'
|
|||||||
|
|
||||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
# Absolute filesystem path to the directory to host projects (with playbooks).
|
||||||
# This directory should not be web-accessible.
|
# This directory should not be web-accessible.
|
||||||
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
|
PROJECTS_ROOT = '/var/lib/awx/projects/'
|
||||||
|
|
||||||
# Absolute filesystem path to the directory to host collections for
|
# Absolute filesystem path to the directory to host collections for
|
||||||
# running inventory imports, isolated playbooks
|
# running inventory imports, isolated playbooks
|
||||||
@@ -125,10 +125,10 @@ AWX_ANSIBLE_COLLECTIONS_PATHS = os.path.join(BASE_DIR, 'vendor', 'awx_ansible_co
|
|||||||
# Absolute filesystem path to the directory for job status stdout (default for
|
# Absolute filesystem path to the directory for job status stdout (default for
|
||||||
# development and tests, default for production defined in production.py). This
|
# development and tests, default for production defined in production.py). This
|
||||||
# directory should not be web-accessible
|
# directory should not be web-accessible
|
||||||
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_output')
|
JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
|
||||||
|
|
||||||
# Absolute filesystem path to the directory to store logs
|
# Absolute filesystem path to the directory to store logs
|
||||||
LOG_ROOT = os.path.join(BASE_DIR)
|
LOG_ROOT = '/var/log/tower/'
|
||||||
|
|
||||||
# The heartbeat file for the tower scheduler
|
# The heartbeat file for the tower scheduler
|
||||||
SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle')
|
SCHEDULE_METADATA_LOCATION = os.path.join(BASE_DIR, '.tower_cycle')
|
||||||
@@ -932,6 +932,14 @@ LOGGING = {
|
|||||||
'backupCount': 5,
|
'backupCount': 5,
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
|
'isolated_manager': {
|
||||||
|
'level': 'WARNING',
|
||||||
|
'class':'logging.handlers.RotatingFileHandler',
|
||||||
|
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
||||||
|
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||||
|
'backupCount': 5,
|
||||||
|
'formatter':'simple',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'django': {
|
'django': {
|
||||||
@@ -981,6 +989,11 @@ LOGGING = {
|
|||||||
'awx.main.wsbroadcast': {
|
'awx.main.wsbroadcast': {
|
||||||
'handlers': ['wsbroadcast'],
|
'handlers': ['wsbroadcast'],
|
||||||
},
|
},
|
||||||
|
'awx.isolated.manager': {
|
||||||
|
'level': 'WARNING',
|
||||||
|
'handlers': ['console', 'file', 'isolated_manager'],
|
||||||
|
'propagate': True
|
||||||
|
},
|
||||||
'awx.isolated.manager.playbooks': {
|
'awx.isolated.manager.playbooks': {
|
||||||
'handlers': ['management_playbooks'],
|
'handlers': ['management_playbooks'],
|
||||||
'propagate': False
|
'propagate': False
|
||||||
|
|||||||
@@ -148,9 +148,9 @@ include(optional('/etc/tower/settings.py'), scope=locals())
|
|||||||
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
|
include(optional('/etc/tower/conf.d/*.py'), scope=locals())
|
||||||
|
|
||||||
# Installed differently in Dockerfile compared to production versions
|
# Installed differently in Dockerfile compared to production versions
|
||||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
|
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||||
|
|
||||||
BASE_VENV_PATH = "/venv/"
|
BASE_VENV_PATH = "/var/lib/awx/venv/"
|
||||||
ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
|
ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
|
||||||
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
||||||
|
|
||||||
|
|||||||
@@ -48,56 +48,12 @@ if "pytest" in sys.modules:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
|
||||||
# This directory should NOT be web-accessible.
|
|
||||||
PROJECTS_ROOT = '/var/lib/awx/projects/'
|
|
||||||
|
|
||||||
# Location for cross-development of inventory plugins
|
# Location for cross-development of inventory plugins
|
||||||
AWX_ANSIBLE_COLLECTIONS_PATHS = '/vendor/awx_ansible_collections'
|
AWX_ANSIBLE_COLLECTIONS_PATHS = '/var/lib/awx/vendor/awx_ansible_collections'
|
||||||
|
|
||||||
# Absolute filesystem path to the directory for job status stdout
|
|
||||||
# This directory should not be web-accessible
|
|
||||||
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
|
|
||||||
|
|
||||||
# The UUID of the system, for HA.
|
# The UUID of the system, for HA.
|
||||||
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
|
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
|
||||||
|
|
||||||
# Local time zone for this installation. Choices can be found here:
|
|
||||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
|
||||||
# although not all choices may be available on all operating systems.
|
|
||||||
# On Unix systems, a value of None will cause Django to use the same
|
|
||||||
# timezone as the operating system.
|
|
||||||
# If running in a Windows environment this must be set to the same as your
|
|
||||||
# system time zone.
|
|
||||||
USE_TZ = True
|
|
||||||
TIME_ZONE = 'UTC'
|
|
||||||
|
|
||||||
# Language code for this installation. All choices can be found here:
|
|
||||||
# http://www.i18nguy.com/unicode/language-identifiers.html
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
# Hardcoded values can leak through source control. Consider loading
|
|
||||||
# the secret key from an environment variable or a file instead.
|
|
||||||
SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
|
|
||||||
|
|
||||||
# HTTP headers and meta keys to search to determine remote host name or IP. Add
|
|
||||||
# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
|
|
||||||
# reverse proxy.
|
|
||||||
REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
|
|
||||||
|
|
||||||
# If Tower is behind a reverse proxy/load balancer, use this setting to
|
|
||||||
# whitelist the proxy IP addresses from which Tower should trust custom
|
|
||||||
# REMOTE_HOST_HEADERS header values
|
|
||||||
# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
|
|
||||||
# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
|
|
||||||
# If this setting is an empty list (the default), the headers specified by
|
|
||||||
# REMOTE_HOST_HEADERS will be trusted unconditionally')
|
|
||||||
PROXY_IP_WHITELIST = []
|
|
||||||
|
|
||||||
# Define additional environment variables to be passed to ansible subprocesses
|
|
||||||
#AWX_TASK_ENV['FOO'] = 'BAR'
|
|
||||||
|
|
||||||
# If set, use -vvv for project updates instead of -v for more output.
|
# If set, use -vvv for project updates instead of -v for more output.
|
||||||
# PROJECT_UPDATE_VVV=True
|
# PROJECT_UPDATE_VVV=True
|
||||||
|
|
||||||
@@ -108,40 +64,6 @@ PROXY_IP_WHITELIST = []
|
|||||||
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
|
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
|
||||||
# WARNING also logs 4xx responses.
|
# WARNING also logs 4xx responses.
|
||||||
|
|
||||||
LOGGING['handlers']['syslog'] = {
|
|
||||||
'level': 'WARNING',
|
|
||||||
'filters': ['require_debug_false'],
|
|
||||||
'class': 'logging.NullHandler',
|
|
||||||
'formatter': 'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGING['loggers']['django.request']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['rest_framework.request']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['awx']['handlers'] = ['console', 'external_logger']
|
|
||||||
LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = [] # propogates to awx
|
|
||||||
LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console', 'external_logger']
|
|
||||||
LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console', 'external_logger']
|
|
||||||
LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['social']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['rbac_migrations']['handlers'] = ['console']
|
|
||||||
LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console']
|
|
||||||
LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'}
|
|
||||||
LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
|
|
||||||
|
|
||||||
|
|
||||||
# Enable the following lines to also log to a file.
|
|
||||||
#LOGGING['handlers']['file'] = {
|
|
||||||
# 'class': 'logging.FileHandler',
|
|
||||||
# 'filename': os.path.join(BASE_DIR, 'awx.log'),
|
|
||||||
# 'formatter': 'simple',
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Enable the following lines to turn on lots of permissions-related logging.
|
# Enable the following lines to turn on lots of permissions-related logging.
|
||||||
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
|
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
|
||||||
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
|
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
|
||||||
@@ -154,74 +76,6 @@ LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'}
|
|||||||
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
||||||
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
|
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# SCM TEST SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Define these variables to enable more complete testing of project support for
|
|
||||||
# SCM updates. The test repositories listed do not have to contain any valid
|
|
||||||
# playbooks.
|
|
||||||
|
|
||||||
try:
|
|
||||||
path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
|
|
||||||
TEST_SSH_KEY_DATA = open(path, 'rb').read()
|
|
||||||
except IOError:
|
|
||||||
TEST_SSH_KEY_DATA = ''
|
|
||||||
|
|
||||||
TEST_GIT_USERNAME = ''
|
|
||||||
TEST_GIT_PASSWORD = ''
|
|
||||||
TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
|
|
||||||
TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
|
|
||||||
TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
|
|
||||||
TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
|
|
||||||
|
|
||||||
TEST_SVN_USERNAME = ''
|
|
||||||
TEST_SVN_PASSWORD = ''
|
|
||||||
TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
|
|
||||||
TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
|
|
||||||
|
|
||||||
# To test repo access via SSH login to localhost.
|
|
||||||
import getpass
|
|
||||||
try:
|
|
||||||
TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
|
|
||||||
except KeyError:
|
|
||||||
TEST_SSH_LOOPBACK_USERNAME = 'root'
|
|
||||||
TEST_SSH_LOOPBACK_PASSWORD = ''
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# INVENTORY IMPORT TEST SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Define these variables to enable more complete testing of inventory import
|
|
||||||
# from cloud providers.
|
|
||||||
|
|
||||||
# EC2 credentials
|
|
||||||
TEST_AWS_ACCESS_KEY_ID = ''
|
|
||||||
TEST_AWS_SECRET_ACCESS_KEY = ''
|
|
||||||
TEST_AWS_REGIONS = 'all'
|
|
||||||
# Check IAM STS credentials
|
|
||||||
TEST_AWS_SECURITY_TOKEN = ''
|
|
||||||
|
|
||||||
# Rackspace credentials
|
|
||||||
TEST_RACKSPACE_USERNAME = ''
|
|
||||||
TEST_RACKSPACE_API_KEY = ''
|
|
||||||
TEST_RACKSPACE_REGIONS = 'all'
|
|
||||||
|
|
||||||
# VMware credentials
|
|
||||||
TEST_VMWARE_HOST = ''
|
|
||||||
TEST_VMWARE_USER = ''
|
|
||||||
TEST_VMWARE_PASSWORD = ''
|
|
||||||
|
|
||||||
# OpenStack credentials
|
|
||||||
TEST_OPENSTACK_HOST = ''
|
|
||||||
TEST_OPENSTACK_USER = ''
|
|
||||||
TEST_OPENSTACK_PASSWORD = ''
|
|
||||||
TEST_OPENSTACK_PROJECT = ''
|
|
||||||
|
|
||||||
# Azure credentials.
|
|
||||||
TEST_AZURE_USERNAME = ''
|
|
||||||
TEST_AZURE_KEY_DATA = ''
|
|
||||||
|
|
||||||
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
BROADCAST_WEBSOCKET_SECRET = '🤖starscream🤖'
|
||||||
BROADCAST_WEBSOCKET_PORT = 8013
|
BROADCAST_WEBSOCKET_PORT = 8013
|
||||||
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
BROADCAST_WEBSOCKET_VERIFY_CERT = False
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
# Copyright (c) 2015 Ansible, Inc. (formerly AnsibleWorks, Inc.)
|
|
||||||
# All Rights Reserved.
|
|
||||||
|
|
||||||
# Local Django settings for AWX project. Rename to "local_settings.py" and
|
|
||||||
# edit as needed for your development environment.
|
|
||||||
|
|
||||||
# All variables defined in awx/settings/development.py will already be loaded
|
|
||||||
# into the global namespace before this file is loaded, to allow for reading
|
|
||||||
# and updating the default settings as needed.
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# MISC PROJECT SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Database settings to use PostgreSQL for development.
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
|
||||||
'NAME': 'awx-dev',
|
|
||||||
'USER': 'awx-dev',
|
|
||||||
'PASSWORD': 'AWXsome1',
|
|
||||||
'HOST': 'localhost',
|
|
||||||
'PORT': '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Use SQLite for unit tests instead of PostgreSQL. If the lines below are
|
|
||||||
# commented out, Django will create the test_awx-dev database in PostgreSQL to
|
|
||||||
# run unit tests.
|
|
||||||
if is_testing(sys.argv):
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'awx.sqlite3'),
|
|
||||||
'TEST': {
|
|
||||||
# Test database cannot be :memory: for tests.
|
|
||||||
'NAME': os.path.join(BASE_DIR, 'awx_test.sqlite3'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# AMQP configuration.
|
|
||||||
BROKER_URL = 'amqp://guest:guest@localhost:5672'
|
|
||||||
|
|
||||||
# Absolute filesystem path to the directory to host projects (with playbooks).
|
|
||||||
# This directory should NOT be web-accessible.
|
|
||||||
PROJECTS_ROOT = os.path.join(BASE_DIR, 'projects')
|
|
||||||
|
|
||||||
# Absolute filesystem path to the directory for job status stdout
|
|
||||||
# This directory should not be web-accessible
|
|
||||||
JOBOUTPUT_ROOT = os.path.join(BASE_DIR, 'job_status')
|
|
||||||
|
|
||||||
# The UUID of the system, for HA.
|
|
||||||
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
|
|
||||||
|
|
||||||
# Local time zone for this installation. Choices can be found here:
|
|
||||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
|
||||||
# although not all choices may be available on all operating systems.
|
|
||||||
# On Unix systems, a value of None will cause Django to use the same
|
|
||||||
# timezone as the operating system.
|
|
||||||
# If running in a Windows environment this must be set to the same as your
|
|
||||||
# system time zone.
|
|
||||||
TIME_ZONE = None
|
|
||||||
|
|
||||||
# Language code for this installation. All choices can be found here:
|
|
||||||
# http://www.i18nguy.com/unicode/language-identifiers.html
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
# Hardcoded values can leak through source control. Consider loading
|
|
||||||
# the secret key from an environment variable or a file instead.
|
|
||||||
SECRET_KEY = 'p7z7g1ql4%6+(6nlebb6hdk7sd^&fnjpal308%n%+p^_e6vo1y'
|
|
||||||
|
|
||||||
# HTTP headers and meta keys to search to determine remote host name or IP. Add
|
|
||||||
# additional items to this list, such as "HTTP_X_FORWARDED_FOR", if behind a
|
|
||||||
# reverse proxy.
|
|
||||||
REMOTE_HOST_HEADERS = ['REMOTE_ADDR', 'REMOTE_HOST']
|
|
||||||
|
|
||||||
# If Tower is behind a reverse proxy/load balancer, use this setting to
|
|
||||||
# whitelist the proxy IP addresses from which Tower should trust custom
|
|
||||||
# REMOTE_HOST_HEADERS header values
|
|
||||||
# REMOTE_HOST_HEADERS = ['HTTP_X_FORWARDED_FOR', ''REMOTE_ADDR', 'REMOTE_HOST']
|
|
||||||
# PROXY_IP_WHITELIST = ['10.0.1.100', '10.0.1.101']
|
|
||||||
# If this setting is an empty list (the default), the headers specified by
|
|
||||||
# REMOTE_HOST_HEADERS will be trusted unconditionally')
|
|
||||||
PROXY_IP_WHITELIST = []
|
|
||||||
|
|
||||||
# Define additional environment variables to be passed to ansible subprocesses
|
|
||||||
#AWX_TASK_ENV['FOO'] = 'BAR'
|
|
||||||
|
|
||||||
# If set, use -vvv for project updates instead of -v for more output.
|
|
||||||
# PROJECT_UPDATE_VVV=True
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# LOGGING SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Enable logging to syslog. Setting level to ERROR captures 500 errors,
|
|
||||||
# WARNING also logs 4xx responses.
|
|
||||||
LOGGING['handlers']['syslog'] = {
|
|
||||||
'level': 'WARNING',
|
|
||||||
'filters': [],
|
|
||||||
'class': 'logging.handlers.SysLogHandler',
|
|
||||||
'address': '/dev/log',
|
|
||||||
'facility': 'local0',
|
|
||||||
'formatter': 'simple',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Enable the following lines to also log to a file.
|
|
||||||
#LOGGING['handlers']['file'] = {
|
|
||||||
# 'class': 'logging.FileHandler',
|
|
||||||
# 'filename': os.path.join(BASE_DIR, 'awx.log'),
|
|
||||||
# 'formatter': 'simple',
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Enable the following lines to turn on lots of permissions-related logging.
|
|
||||||
#LOGGING['loggers']['awx.main.access']['level'] = 'DEBUG'
|
|
||||||
#LOGGING['loggers']['awx.main.signals']['level'] = 'DEBUG'
|
|
||||||
#LOGGING['loggers']['awx.main.permissions']['level'] = 'DEBUG'
|
|
||||||
|
|
||||||
# Enable the following line to turn on database settings logging.
|
|
||||||
#LOGGING['loggers']['awx.conf']['level'] = 'DEBUG'
|
|
||||||
|
|
||||||
# Enable the following lines to turn on LDAP auth logging.
|
|
||||||
#LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console']
|
|
||||||
#LOGGING['loggers']['django_auth_ldap']['level'] = 'DEBUG'
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# SCM TEST SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Define these variables to enable more complete testing of project support for
|
|
||||||
# SCM updates. The test repositories listed do not have to contain any valid
|
|
||||||
# playbooks.
|
|
||||||
|
|
||||||
try:
|
|
||||||
path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa'))
|
|
||||||
TEST_SSH_KEY_DATA = file(path, 'rb').read()
|
|
||||||
except IOError:
|
|
||||||
TEST_SSH_KEY_DATA = ''
|
|
||||||
|
|
||||||
TEST_GIT_USERNAME = ''
|
|
||||||
TEST_GIT_PASSWORD = ''
|
|
||||||
TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA
|
|
||||||
TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com.git'
|
|
||||||
TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs.git'
|
|
||||||
TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/product-docs.git'
|
|
||||||
|
|
||||||
TEST_SVN_USERNAME = ''
|
|
||||||
TEST_SVN_PASSWORD = ''
|
|
||||||
TEST_SVN_PUBLIC_HTTPS = 'https://github.com/ansible/ansible.github.com'
|
|
||||||
TEST_SVN_PRIVATE_HTTPS = 'https://github.com/ansible/product-docs'
|
|
||||||
|
|
||||||
# To test repo access via SSH login to localhost.
|
|
||||||
import getpass
|
|
||||||
TEST_SSH_LOOPBACK_USERNAME = getpass.getuser()
|
|
||||||
TEST_SSH_LOOPBACK_PASSWORD = ''
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
# INVENTORY IMPORT TEST SETTINGS
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# Define these variables to enable more complete testing of inventory import
|
|
||||||
# from cloud providers.
|
|
||||||
|
|
||||||
# EC2 credentials
|
|
||||||
TEST_AWS_ACCESS_KEY_ID = ''
|
|
||||||
TEST_AWS_SECRET_ACCESS_KEY = ''
|
|
||||||
TEST_AWS_REGIONS = 'all'
|
|
||||||
# Check IAM STS credentials
|
|
||||||
TEST_AWS_SECURITY_TOKEN = ''
|
|
||||||
|
|
||||||
|
|
||||||
# Rackspace credentials
|
|
||||||
TEST_RACKSPACE_USERNAME = ''
|
|
||||||
TEST_RACKSPACE_API_KEY = ''
|
|
||||||
TEST_RACKSPACE_REGIONS = 'all'
|
|
||||||
|
|
||||||
# VMware credentials
|
|
||||||
TEST_VMWARE_HOST = ''
|
|
||||||
TEST_VMWARE_USER = ''
|
|
||||||
TEST_VMWARE_PASSWORD = ''
|
|
||||||
|
|
||||||
# OpenStack credentials
|
|
||||||
TEST_OPENSTACK_HOST = ''
|
|
||||||
TEST_OPENSTACK_USER = ''
|
|
||||||
TEST_OPENSTACK_PASSWORD = ''
|
|
||||||
TEST_OPENSTACK_PROJECT = ''
|
|
||||||
|
|
||||||
# Azure credentials.
|
|
||||||
TEST_AZURE_USERNAME = ''
|
|
||||||
TEST_AZURE_KEY_DATA = ''
|
|
||||||
@@ -30,10 +30,6 @@ SECRET_KEY = None
|
|||||||
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
# See https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
# Absolute filesystem path to the directory for job status stdout
|
|
||||||
# This directory should not be web-accessible
|
|
||||||
JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
|
|
||||||
|
|
||||||
# The heartbeat file for the tower scheduler
|
# The heartbeat file for the tower scheduler
|
||||||
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle'
|
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle'
|
||||||
|
|
||||||
@@ -46,15 +42,6 @@ AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
|
|||||||
|
|
||||||
AWX_ISOLATED_USERNAME = 'awx'
|
AWX_ISOLATED_USERNAME = 'awx'
|
||||||
|
|
||||||
LOGGING['handlers']['tower_warnings']['filename'] = '/var/log/tower/tower.log' # noqa
|
|
||||||
LOGGING['handlers']['callback_receiver']['filename'] = '/var/log/tower/callback_receiver.log' # noqa
|
|
||||||
LOGGING['handlers']['dispatcher']['filename'] = '/var/log/tower/dispatcher.log' # noqa
|
|
||||||
LOGGING['handlers']['wsbroadcast']['filename'] = '/var/log/tower/wsbroadcast.log' # noqa
|
|
||||||
LOGGING['handlers']['task_system']['filename'] = '/var/log/tower/task_system.log' # noqa
|
|
||||||
LOGGING['handlers']['management_playbooks']['filename'] = '/var/log/tower/management_playbooks.log' # noqa
|
|
||||||
LOGGING['handlers']['system_tracking_migrations']['filename'] = '/var/log/tower/tower_system_tracking_migrations.log' # noqa
|
|
||||||
LOGGING['handlers']['rbac_migrations']['filename'] = '/var/log/tower/tower_rbac_migrations.log' # noqa
|
|
||||||
|
|
||||||
# Store a snapshot of default settings at this point before loading any
|
# Store a snapshot of default settings at this point before loading any
|
||||||
# customizable config files.
|
# customizable config files.
|
||||||
DEFAULTS_SNAPSHOT = {}
|
DEFAULTS_SNAPSHOT = {}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ The UI is built using [ReactJS](https://reactjs.org/docs/getting-started.html) a
|
|||||||
|
|
||||||
The AWX UI requires the following:
|
The AWX UI requires the following:
|
||||||
|
|
||||||
- Node 10.x LTS
|
- Node 14.x LTS
|
||||||
- NPM 6.x LTS
|
- NPM 6.x LTS
|
||||||
|
|
||||||
Run the following to install all the dependencies:
|
Run the following to install all the dependencies:
|
||||||
|
|||||||
68
awx/ui_next/package-lock.json
generated
68
awx/ui_next/package-lock.json
generated
@@ -3387,12 +3387,18 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"axios": {
|
"axios": {
|
||||||
"version": "0.18.1",
|
"version": "0.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||||
"integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==",
|
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"follow-redirects": "1.5.10",
|
"follow-redirects": "^1.10.0"
|
||||||
"is-buffer": "^2.0.2"
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"axobject-query": {
|
"axobject-query": {
|
||||||
@@ -4195,6 +4201,16 @@
|
|||||||
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
"integrity": "sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"bindings": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"file-uri-to-path": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"bluebird": {
|
"bluebird": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
@@ -5961,6 +5977,7 @@
|
|||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
|
||||||
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
|
||||||
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
@@ -7911,6 +7928,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"file-uri-to-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"filesize": {
|
"filesize": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.0.1.tgz",
|
||||||
@@ -8110,6 +8134,7 @@
|
|||||||
"version": "1.5.10",
|
"version": "1.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
|
||||||
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
|
"integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
|
||||||
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"debug": "=3.1.0"
|
"debug": "=3.1.0"
|
||||||
}
|
}
|
||||||
@@ -9500,11 +9525,6 @@
|
|||||||
"call-bind": "^1.0.0"
|
"call-bind": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"is-buffer": {
|
|
||||||
"version": "2.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
|
||||||
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
|
|
||||||
},
|
|
||||||
"is-callable": {
|
"is-callable": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.2.tgz",
|
||||||
@@ -10315,7 +10335,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"nan": "^2.12.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"is-buffer": {
|
"is-buffer": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
@@ -11731,7 +11755,8 @@
|
|||||||
"ms": {
|
"ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
|
||||||
|
"dev": true
|
||||||
},
|
},
|
||||||
"multicast-dns": {
|
"multicast-dns": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
@@ -11755,6 +11780,13 @@
|
|||||||
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
|
"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"nan": {
|
||||||
|
"version": "2.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz",
|
||||||
|
"integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"nanomatch": {
|
"nanomatch": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||||
@@ -17683,7 +17715,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"nan": "^2.12.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -18364,7 +18400,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true,
|
||||||
|
"requires": {
|
||||||
|
"bindings": "^1.5.0",
|
||||||
|
"nan": "^2.12.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"glob-parent": {
|
"glob-parent": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"@patternfly/react-icons": "4.7.22",
|
"@patternfly/react-icons": "4.7.22",
|
||||||
"@patternfly/react-table": "^4.19.15",
|
"@patternfly/react-table": "^4.19.15",
|
||||||
"ansi-to-html": "^0.6.11",
|
"ansi-to-html": "^0.6.11",
|
||||||
"axios": "^0.18.1",
|
"axios": "^0.21.1",
|
||||||
"codemirror": "^5.47.0",
|
"codemirror": "^5.47.0",
|
||||||
"d3": "^5.12.0",
|
"d3": "^5.12.0",
|
||||||
"dagre": "^0.8.4",
|
"dagre": "^0.8.4",
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class Jobs extends RelaunchMixin(Base) {
|
|||||||
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id, type) {
|
||||||
|
return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`);
|
||||||
|
}
|
||||||
|
|
||||||
readDetail(id, type) {
|
readDetail(id, type) {
|
||||||
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
|
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function AdHocCommands({ adHocItems, i18n, hasListItems }) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
const {
|
const {
|
||||||
isloading: isLaunchLoading,
|
isLoading: isLaunchLoading,
|
||||||
error: launchError,
|
error: launchError,
|
||||||
request: launchAdHocCommands,
|
request: launchAdHocCommands,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
|
|||||||
return <ContentError error={error} />;
|
return <ContentError error={error} />;
|
||||||
}
|
}
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading error={error} />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Form>
|
<Form>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class AddResourceRole extends React.Component {
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
maxEnabledStep,
|
maxEnabledStep,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
const { onClose, roles, i18n } = this.props;
|
const { onClose, roles, i18n, resource } = this.props;
|
||||||
|
|
||||||
// Object roles can be user only, so we remove them when
|
// Object roles can be user only, so we remove them when
|
||||||
// showing role choices for team access
|
// showing role choices for team access
|
||||||
@@ -235,18 +235,24 @@ class AddResourceRole extends React.Component {
|
|||||||
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
|
t`Choose the type of resource that will be receiving new roles. For example, if you'd like to add new roles to a set of users please choose Users and click Next. You'll be able to select the specific resources in the next step.`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectableCard
|
<SelectableCard
|
||||||
isSelected={selectedResource === 'users'}
|
isSelected={selectedResource === 'users'}
|
||||||
label={i18n._(t`Users`)}
|
label={i18n._(t`Users`)}
|
||||||
dataCy="add-role-users"
|
dataCy="add-role-users"
|
||||||
|
ariaLabel={i18n._(t`Users`)}
|
||||||
onClick={() => this.handleResourceSelect('users')}
|
onClick={() => this.handleResourceSelect('users')}
|
||||||
/>
|
/>
|
||||||
<SelectableCard
|
{resource?.type === 'credential' &&
|
||||||
isSelected={selectedResource === 'teams'}
|
!resource?.organization ? null : (
|
||||||
label={i18n._(t`Teams`)}
|
<SelectableCard
|
||||||
dataCy="add-role-teams"
|
isSelected={selectedResource === 'teams'}
|
||||||
onClick={() => this.handleResourceSelect('teams')}
|
label={i18n._(t`Teams`)}
|
||||||
/>
|
dataCy="add-role-teams"
|
||||||
|
ariaLabel={i18n._(t`Teams`)}
|
||||||
|
onClick={() => this.handleResourceSelect('teams')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableNext: selectedResource !== null,
|
enableNext: selectedResource !== null,
|
||||||
@@ -329,10 +335,12 @@ AddResourceRole.propTypes = {
|
|||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
onSave: PropTypes.func.isRequired,
|
onSave: PropTypes.func.isRequired,
|
||||||
roles: PropTypes.shape(),
|
roles: PropTypes.shape(),
|
||||||
|
resource: PropTypes.shape(),
|
||||||
};
|
};
|
||||||
|
|
||||||
AddResourceRole.defaultProps = {
|
AddResourceRole.defaultProps = {
|
||||||
roles: {},
|
roles: {},
|
||||||
|
resource: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AddResourceRole as _AddResourceRole };
|
export { AddResourceRole as _AddResourceRole };
|
||||||
|
|||||||
@@ -221,4 +221,22 @@ describe('<_AddResourceRole />', () => {
|
|||||||
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
|
expect(TeamsAPI.associateRole).toHaveBeenCalledTimes(2);
|
||||||
expect(handleSave).toHaveBeenCalled();
|
expect(handleSave).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should not display team as a choice in case credential does not have organization', () => {
|
||||||
|
const spy = jest.spyOn(_AddResourceRole.prototype, 'handleResourceSelect');
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<AddResourceRole
|
||||||
|
onClose={() => {}}
|
||||||
|
onSave={() => {}}
|
||||||
|
roles={roles}
|
||||||
|
resource={{ type: 'credential', organization: null }}
|
||||||
|
/>,
|
||||||
|
{ context: { network: { handleHttpError: () => {} } } }
|
||||||
|
).find('AddResourceRole');
|
||||||
|
const selectableCardWrapper = wrapper.find('SelectableCard');
|
||||||
|
expect(selectableCardWrapper.length).toBe(1);
|
||||||
|
selectableCardWrapper.first().simulate('click');
|
||||||
|
expect(spy).toHaveBeenCalledWith('users');
|
||||||
|
expect(wrapper.state('selectedResource')).toBe('users');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ const mockData = [
|
|||||||
{
|
{
|
||||||
key: 'baz',
|
key: 'baz',
|
||||||
label: 'Baz',
|
label: 'Baz',
|
||||||
value: '/venv/baz/',
|
value: '/var/lib/awx/venv/baz/',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'default',
|
key: 'default',
|
||||||
label: 'Default',
|
label: 'Default',
|
||||||
value: '/venv/ansible/',
|
value: '/var/lib/awx/venv/ansible/',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'codemirror/mode/javascript/javascript';
|
|||||||
import 'codemirror/mode/yaml/yaml';
|
import 'codemirror/mode/yaml/yaml';
|
||||||
import 'codemirror/mode/jinja2/jinja2';
|
import 'codemirror/mode/jinja2/jinja2';
|
||||||
import 'codemirror/lib/codemirror.css';
|
import 'codemirror/lib/codemirror.css';
|
||||||
|
import 'codemirror/addon/display/placeholder';
|
||||||
|
|
||||||
const LINE_HEIGHT = 24;
|
const LINE_HEIGHT = 24;
|
||||||
const PADDING = 12;
|
const PADDING = 12;
|
||||||
@@ -55,6 +56,17 @@ const CodeMirror = styled(ReactCodeMirror)`
|
|||||||
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
|
background-color: var(--pf-c-form-control--disabled--BackgroundColor);
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
${props =>
|
||||||
|
props.options &&
|
||||||
|
props.options.placeholder &&
|
||||||
|
`
|
||||||
|
.CodeMirror-empty {
|
||||||
|
pre.CodeMirror-placeholder {
|
||||||
|
color: var(--pf-c-form-control--placeholder--Color);
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function CodeMirrorInput({
|
function CodeMirrorInput({
|
||||||
@@ -66,6 +78,7 @@ function CodeMirrorInput({
|
|||||||
rows,
|
rows,
|
||||||
fullHeight,
|
fullHeight,
|
||||||
className,
|
className,
|
||||||
|
placeholder,
|
||||||
}) {
|
}) {
|
||||||
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
|
// Workaround for CodeMirror bug: If CodeMirror renders in a modal on the
|
||||||
// modal's initial render, it appears as an empty box due to mis-calculated
|
// modal's initial render, it appears as an empty box due to mis-calculated
|
||||||
@@ -92,6 +105,7 @@ function CodeMirrorInput({
|
|||||||
smartIndent: false,
|
smartIndent: false,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
lineWrapping: true,
|
lineWrapping: true,
|
||||||
|
placeholder,
|
||||||
readOnly,
|
readOnly,
|
||||||
}}
|
}}
|
||||||
fullHeight={fullHeight}
|
fullHeight={fullHeight}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
EmptyState as PFEmptyState,
|
EmptyState as PFEmptyState,
|
||||||
EmptyStateBody,
|
EmptyStateIcon,
|
||||||
|
Spinner,
|
||||||
} from '@patternfly/react-core';
|
} from '@patternfly/react-core';
|
||||||
|
|
||||||
const EmptyState = styled(PFEmptyState)`
|
const EmptyState = styled(PFEmptyState)`
|
||||||
--pf-c-empty-state--m-lg--MaxWidth: none;
|
--pf-c-empty-state--m-lg--MaxWidth: none;
|
||||||
|
min-height: 250px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// TODO: Better loading state - skeleton lines / spinner, etc.
|
// TODO: Better loading state - skeleton lines / spinner, etc.
|
||||||
const ContentLoading = ({ className, i18n }) => (
|
const ContentLoading = ({ className }) => {
|
||||||
<EmptyState variant="full" className={className}>
|
return (
|
||||||
<EmptyStateBody>{i18n._(t`Loading...`)}</EmptyStateBody>
|
<EmptyState variant="full" className={className}>
|
||||||
</EmptyState>
|
<EmptyStateIcon variant="container" component={Spinner} />
|
||||||
);
|
</EmptyState>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export { ContentLoading as _ContentLoading };
|
export { ContentLoading as _ContentLoading };
|
||||||
export default withI18n()(ContentLoading);
|
export default ContentLoading;
|
||||||
|
|||||||
@@ -16,10 +16,17 @@ function CredentialChip({ credential, i18n, i18nHash, ...props }) {
|
|||||||
type = toTitleCase(credential.kind);
|
type = toTitleCase(credential.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildCredentialName = () => {
|
||||||
|
if (credential.kind === 'vault' && credential.inputs?.vault_id) {
|
||||||
|
return `${credential.name} | ${credential.inputs.vault_id}`;
|
||||||
|
}
|
||||||
|
return `${credential.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chip {...props}>
|
<Chip {...props}>
|
||||||
<strong>{type}: </strong>
|
<strong>{type}: </strong>
|
||||||
{credential.name}
|
{buildCredentialName()}
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
Normal file
23
awx/ui_next/src/components/LoadingSpinner/LoadingSpinner.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Spinner } from '@patternfly/react-core';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const UpdatingContent = styled.div`
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 300;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
& + * {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LoadingSpinner = () => (
|
||||||
|
<UpdatingContent>
|
||||||
|
<Spinner />
|
||||||
|
</UpdatingContent>
|
||||||
|
);
|
||||||
|
export default LoadingSpinner;
|
||||||
1
awx/ui_next/src/components/LoadingSpinner/index.js
Normal file
1
awx/ui_next/src/components/LoadingSpinner/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './LoadingSpinner';
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
arrayOf,
|
arrayOf,
|
||||||
bool,
|
bool,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
string,
|
string,
|
||||||
oneOfType,
|
oneOfType,
|
||||||
} from 'prop-types';
|
} from 'prop-types';
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { FormGroup } from '@patternfly/react-core';
|
import { FormGroup } from '@patternfly/react-core';
|
||||||
@@ -39,13 +39,13 @@ function CredentialLookup({
|
|||||||
credentialTypeKind,
|
credentialTypeKind,
|
||||||
credentialTypeNamespace,
|
credentialTypeNamespace,
|
||||||
value,
|
value,
|
||||||
history,
|
|
||||||
i18n,
|
i18n,
|
||||||
tooltip,
|
tooltip,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
autoPopulate,
|
autoPopulate,
|
||||||
multiple,
|
multiple,
|
||||||
}) {
|
}) {
|
||||||
|
const history = useHistory();
|
||||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||||
const {
|
const {
|
||||||
result: { count, credentials, relatedSearchableKeys, searchableKeys },
|
result: { count, credentials, relatedSearchableKeys, searchableKeys },
|
||||||
@@ -72,22 +72,28 @@ function CredentialLookup({
|
|||||||
...typeNamespaceParams,
|
...typeNamespaceParams,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
CredentialsAPI.readOptions,
|
CredentialsAPI.readOptions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (autoPopulate) {
|
if (autoPopulate) {
|
||||||
autoPopulateLookup(data.results);
|
autoPopulateLookup(data.results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const searchKeys = Object.keys(
|
||||||
|
actionsResponse.data.actions?.GET || {}
|
||||||
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable);
|
||||||
|
const item = searchKeys.indexOf('type');
|
||||||
|
if (item) {
|
||||||
|
searchKeys[item] = 'credential_type__kind';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count: data.count,
|
count: data.count,
|
||||||
credentials: data.results,
|
credentials: data.results,
|
||||||
relatedSearchableKeys: (
|
relatedSearchableKeys: (
|
||||||
actionsResponse?.data?.related_search_fields || []
|
actionsResponse?.data?.related_search_fields || []
|
||||||
).map(val => val.slice(0, -8)),
|
).map(val => val.slice(0, -8)),
|
||||||
searchableKeys: Object.keys(
|
searchableKeys: searchKeys,
|
||||||
actionsResponse.data?.actions?.GET || {}
|
|
||||||
).filter(key => actionsResponse.data?.actions?.GET[key]?.filterable),
|
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
autoPopulate,
|
autoPopulate,
|
||||||
@@ -222,4 +228,4 @@ CredentialLookup.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export { CredentialLookup as _CredentialLookup };
|
export { CredentialLookup as _CredentialLookup };
|
||||||
export default withI18n()(withRouter(CredentialLookup));
|
export default withI18n()(CredentialLookup);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import useRequest from '../../util/useRequest';
|
|||||||
import Lookup from './Lookup';
|
import Lookup from './Lookup';
|
||||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('instance_groups', {
|
const QS_CONFIG = getQSConfig('instance-groups', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const QS_CONFIG = getQSConfig('inventory', {
|
|||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
|
role_level: 'use_role',
|
||||||
});
|
});
|
||||||
|
|
||||||
function InventoryLookup({
|
function InventoryLookup({
|
||||||
@@ -29,6 +30,7 @@ function InventoryLookup({
|
|||||||
fieldId,
|
fieldId,
|
||||||
promptId,
|
promptId,
|
||||||
promptName,
|
promptName,
|
||||||
|
isOverrideDisabled,
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
result: {
|
result: {
|
||||||
@@ -57,8 +59,10 @@ function InventoryLookup({
|
|||||||
searchableKeys: Object.keys(
|
searchableKeys: Object.keys(
|
||||||
actionsResponse.data.actions?.GET || {}
|
actionsResponse.data.actions?.GET || {}
|
||||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||||
canEdit: Boolean(actionsResponse.data.actions.POST),
|
canEdit:
|
||||||
|
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [history.location]),
|
}, [history.location]),
|
||||||
{
|
{
|
||||||
inventories: [],
|
inventories: [],
|
||||||
@@ -195,11 +199,13 @@ InventoryLookup.propTypes = {
|
|||||||
value: Inventory,
|
value: Inventory,
|
||||||
onChange: func.isRequired,
|
onChange: func.isRequired,
|
||||||
required: bool,
|
required: bool,
|
||||||
|
isOverrideDisabled: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
InventoryLookup.defaultProps = {
|
InventoryLookup.defaultProps = {
|
||||||
value: null,
|
value: null,
|
||||||
required: false,
|
required: false,
|
||||||
|
isOverrideDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withI18n()(withRouter(InventoryLookup));
|
export default withI18n()(withRouter(InventoryLookup));
|
||||||
|
|||||||
87
awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
Normal file
87
awx/ui_next/src/components/Lookup/InventoryLookup.test.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import InventoryLookup from './InventoryLookup';
|
||||||
|
import { InventoriesAPI } from '../../api';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
|
const mockedInventories = {
|
||||||
|
data: {
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{ id: 2, name: 'Bar' },
|
||||||
|
{ id: 3, name: 'Baz' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('InventoryLookup', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
InventoriesAPI.read.mockResolvedValue(mockedInventories);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render successfully and fetch data', async () => {
|
||||||
|
InventoriesAPI.readOptions.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
POST: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory lookup should be enabled', async () => {
|
||||||
|
InventoriesAPI.readOptions.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventoryLookup isOverrideDisabled onChange={() => {}} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory lookup should be disabled', async () => {
|
||||||
|
InventoriesAPI.readOptions.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(InventoriesAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(wrapper.find('InventoryLookup')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,6 +71,16 @@ function MultiCredentialsLookup(props) {
|
|||||||
loadCredentials(params, selectedType.id),
|
loadCredentials(params, selectedType.id),
|
||||||
CredentialsAPI.readOptions(),
|
CredentialsAPI.readOptions(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
results.map(result => {
|
||||||
|
if (result.kind === 'vault' && result.inputs?.vault_id) {
|
||||||
|
result.label = `${result.name} | ${result.inputs.vault_id}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.label = `${result.name}`;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
credentials: results,
|
credentials: results,
|
||||||
credentialsCount: count,
|
credentialsCount: count,
|
||||||
@@ -108,7 +118,6 @@ function MultiCredentialsLookup(props) {
|
|||||||
credential={item}
|
credential={item}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const isVault = selectedType?.kind === 'vault';
|
const isVault = selectedType?.kind === 'vault';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -187,6 +196,7 @@ function MultiCredentialsLookup(props) {
|
|||||||
relatedSearchableKeys={relatedSearchableKeys}
|
relatedSearchableKeys={relatedSearchableKeys}
|
||||||
multiple={isVault}
|
multiple={isVault}
|
||||||
header={i18n._(t`Credentials`)}
|
header={i18n._(t`Credentials`)}
|
||||||
|
displayKey={isVault ? 'label' : 'name'}
|
||||||
name="credentials"
|
name="credentials"
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
readOnly={!canDelete}
|
readOnly={!canDelete}
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
name: 'Cred 5',
|
name: 'Cred 5',
|
||||||
url: 'www.google.com',
|
url: 'www.google.com',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
credential_type: 5,
|
||||||
|
kind: 'vault',
|
||||||
|
name: 'Cred 6',
|
||||||
|
url: 'www.google.com',
|
||||||
|
inputs: { vault_id: 'vault ID' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
credential_type: 5,
|
||||||
|
kind: 'vault',
|
||||||
|
name: 'Cred 7',
|
||||||
|
url: 'www.google.com',
|
||||||
|
inputs: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
count: 3,
|
count: 3,
|
||||||
},
|
},
|
||||||
@@ -196,7 +213,13 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
expect(CredentialsAPI.read).toHaveBeenCalledTimes(2);
|
||||||
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
expect(wrapper.find('OptionsList').prop('options')).toEqual([
|
||||||
{ id: 1, kind: 'cloud', name: 'New Cred', url: 'www.google.com' },
|
{
|
||||||
|
id: 1,
|
||||||
|
kind: 'cloud',
|
||||||
|
name: 'New Cred',
|
||||||
|
url: 'www.google.com',
|
||||||
|
label: 'New Cred',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,6 +291,36 @@ describe('<MultiCredentialsLookup />', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should properly render vault credential labels', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<MultiCredentialsLookup
|
||||||
|
value={credentials}
|
||||||
|
tooltip="This is credentials look up"
|
||||||
|
onChange={() => {}}
|
||||||
|
onError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const searchButton = await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Button[aria-label="Search"]'
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
searchButton.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const typeSelect = wrapper.find('AnsibleSelect');
|
||||||
|
act(() => {
|
||||||
|
typeSelect.invoke('onChange')({}, 500);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
const optionsList = wrapper.find('OptionsList');
|
||||||
|
expect(optionsList.prop('multiple')).toEqual(true);
|
||||||
|
expect(wrapper.find('CheckboxListItem[label="Cred 6 | vault ID"]'));
|
||||||
|
expect(wrapper.find('CheckboxListItem[label="Cred 7"]'));
|
||||||
|
});
|
||||||
|
|
||||||
test('should allow multiple vault credentials with no vault id', async () => {
|
test('should allow multiple vault credentials with no vault id', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const QS_CONFIG = getQSConfig('project', {
|
|||||||
page: 1,
|
page: 1,
|
||||||
page_size: 5,
|
page_size: 5,
|
||||||
order_by: 'name',
|
order_by: 'name',
|
||||||
|
role_level: 'use_role',
|
||||||
});
|
});
|
||||||
|
|
||||||
function ProjectLookup({
|
function ProjectLookup({
|
||||||
@@ -31,6 +32,7 @@ function ProjectLookup({
|
|||||||
value,
|
value,
|
||||||
onBlur,
|
onBlur,
|
||||||
history,
|
history,
|
||||||
|
isOverrideDisabled,
|
||||||
}) {
|
}) {
|
||||||
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
const autoPopulateLookup = useAutoPopulateLookup(onChange);
|
||||||
const {
|
const {
|
||||||
@@ -57,8 +59,10 @@ function ProjectLookup({
|
|||||||
searchableKeys: Object.keys(
|
searchableKeys: Object.keys(
|
||||||
actionsResponse.data.actions?.GET || {}
|
actionsResponse.data.actions?.GET || {}
|
||||||
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
|
||||||
canEdit: Boolean(actionsResponse.data.actions.POST),
|
canEdit:
|
||||||
|
Boolean(actionsResponse.data.actions.POST) || isOverrideDisabled,
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [autoPopulate, autoPopulateLookup, history.location.search]),
|
}, [autoPopulate, autoPopulateLookup, history.location.search]),
|
||||||
{
|
{
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -160,6 +164,7 @@ ProjectLookup.propTypes = {
|
|||||||
required: bool,
|
required: bool,
|
||||||
tooltip: string,
|
tooltip: string,
|
||||||
value: Project,
|
value: Project,
|
||||||
|
isOverrideDisabled: bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
ProjectLookup.defaultProps = {
|
ProjectLookup.defaultProps = {
|
||||||
@@ -170,6 +175,7 @@ ProjectLookup.defaultProps = {
|
|||||||
required: false,
|
required: false,
|
||||||
tooltip: '',
|
tooltip: '',
|
||||||
value: null,
|
value: null,
|
||||||
|
isOverrideDisabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ProjectLookup as _ProjectLookup };
|
export { ProjectLookup as _ProjectLookup };
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import ProjectLookup from './ProjectLookup';
|
|||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
|
|
||||||
describe('<ProjectLookup />', () => {
|
describe('<ProjectLookup />', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
test('should auto-select project when only one available and autoPopulate prop is true', async () => {
|
test('should auto-select project when only one available and autoPopulate prop is true', async () => {
|
||||||
ProjectsAPI.read.mockReturnValue({
|
ProjectsAPI.read.mockReturnValue({
|
||||||
data: {
|
data: {
|
||||||
@@ -48,4 +52,46 @@ describe('<ProjectLookup />', () => {
|
|||||||
});
|
});
|
||||||
expect(onChange).not.toHaveBeenCalled();
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('project lookup should be enabled', async () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
ProjectsAPI.readOptions.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ProjectLookup isOverrideDisabled onChange={() => {}} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project lookup should be disabled', async () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
ProjectsAPI.readOptions.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
GET: {},
|
||||||
|
},
|
||||||
|
related_search_fields: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<ProjectLookup onChange={() => {}} />);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(ProjectsAPI.read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(wrapper.find('ProjectLookup')).toHaveLength(1);
|
||||||
|
expect(wrapper.find('Lookup').prop('isDisabled')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { DataList } from '@patternfly/react-core';
|
import { DataList } from '@patternfly/react-core';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter, useHistory, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import ListHeader from '../ListHeader';
|
import ListHeader from '../ListHeader';
|
||||||
import ContentEmpty from '../ContentEmpty';
|
import ContentEmpty from '../ContentEmpty';
|
||||||
@@ -21,167 +22,155 @@ import {
|
|||||||
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
import { QSConfig, SearchColumns, SortColumns } from '../../types';
|
||||||
|
|
||||||
import PaginatedDataListItem from './PaginatedDataListItem';
|
import PaginatedDataListItem from './PaginatedDataListItem';
|
||||||
|
import LoadingSpinner from '../LoadingSpinner';
|
||||||
|
|
||||||
class PaginatedDataList extends React.Component {
|
function PaginatedDataList({
|
||||||
constructor(props) {
|
items,
|
||||||
super(props);
|
onRowClick,
|
||||||
this.handleSetPage = this.handleSetPage.bind(this);
|
contentError,
|
||||||
this.handleSetPageSize = this.handleSetPageSize.bind(this);
|
hasContentLoading,
|
||||||
this.handleListItemSelect = this.handleListItemSelect.bind(this);
|
emptyStateControls,
|
||||||
}
|
itemCount,
|
||||||
|
qsConfig,
|
||||||
handleListItemSelect = (id = 0) => {
|
renderItem,
|
||||||
const { items, onRowClick } = this.props;
|
toolbarSearchColumns,
|
||||||
|
toolbarSearchableKeys,
|
||||||
|
toolbarRelatedSearchableKeys,
|
||||||
|
toolbarSortColumns,
|
||||||
|
pluralizedItemName,
|
||||||
|
showPageSizeOptions,
|
||||||
|
location,
|
||||||
|
i18n,
|
||||||
|
renderToolbar,
|
||||||
|
}) {
|
||||||
|
const { search, pathname } = useLocation();
|
||||||
|
const history = useHistory();
|
||||||
|
const handleListItemSelect = (id = 0) => {
|
||||||
const match = items.find(item => item.id === Number(id));
|
const match = items.find(item => item.id === Number(id));
|
||||||
onRowClick(match);
|
onRowClick(match);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSetPage(event, pageNumber) {
|
const handleSetPage = (event, pageNumber) => {
|
||||||
const { history, qsConfig } = this.props;
|
|
||||||
const { search } = history.location;
|
|
||||||
const oldParams = parseQueryString(qsConfig, search);
|
const oldParams = parseQueryString(qsConfig, search);
|
||||||
this.pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
|
pushHistoryState(replaceParams(oldParams, { page: pageNumber }));
|
||||||
}
|
};
|
||||||
|
|
||||||
handleSetPageSize(event, pageSize, page) {
|
const handleSetPageSize = (event, pageSize, page) => {
|
||||||
const { history, qsConfig } = this.props;
|
|
||||||
const { search } = history.location;
|
|
||||||
const oldParams = parseQueryString(qsConfig, search);
|
const oldParams = parseQueryString(qsConfig, search);
|
||||||
this.pushHistoryState(
|
pushHistoryState(replaceParams(oldParams, { page_size: pageSize, page }));
|
||||||
replaceParams(oldParams, { page_size: pageSize, page })
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pushHistoryState(params) {
|
const pushHistoryState = params => {
|
||||||
const { history, qsConfig } = this.props;
|
|
||||||
const { pathname } = history.location;
|
|
||||||
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
const encodedParams = encodeNonDefaultQueryString(qsConfig, params);
|
||||||
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
history.push(encodedParams ? `${pathname}?${encodedParams}` : pathname);
|
||||||
}
|
};
|
||||||
|
|
||||||
render() {
|
const searchColumns = toolbarSearchColumns.length
|
||||||
const {
|
? toolbarSearchColumns
|
||||||
contentError,
|
: [
|
||||||
hasContentLoading,
|
{
|
||||||
emptyStateControls,
|
name: i18n._(t`Name`),
|
||||||
items,
|
key: 'name',
|
||||||
itemCount,
|
isDefault: true,
|
||||||
qsConfig,
|
},
|
||||||
renderItem,
|
];
|
||||||
toolbarSearchColumns,
|
const sortColumns = toolbarSortColumns.length
|
||||||
toolbarSearchableKeys,
|
? toolbarSortColumns
|
||||||
toolbarRelatedSearchableKeys,
|
: [
|
||||||
toolbarSortColumns,
|
{
|
||||||
pluralizedItemName,
|
name: i18n._(t`Name`),
|
||||||
showPageSizeOptions,
|
key: 'name',
|
||||||
location,
|
},
|
||||||
i18n,
|
];
|
||||||
renderToolbar,
|
const queryParams = parseQueryString(qsConfig, location.search);
|
||||||
} = this.props;
|
|
||||||
const searchColumns = toolbarSearchColumns.length
|
|
||||||
? toolbarSearchColumns
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const sortColumns = toolbarSortColumns.length
|
|
||||||
? toolbarSortColumns
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const queryParams = parseQueryString(qsConfig, location.search);
|
|
||||||
|
|
||||||
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
const dataListLabel = i18n._(t`${pluralizedItemName} List`);
|
||||||
const emptyContentMessage = i18n._(
|
const emptyContentMessage = i18n._(
|
||||||
t`Please add ${pluralizedItemName} to populate this list `
|
t`Please add ${pluralizedItemName} to populate this list `
|
||||||
|
);
|
||||||
|
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
||||||
|
|
||||||
|
let Content;
|
||||||
|
if (hasContentLoading && items.length <= 0) {
|
||||||
|
Content = <ContentLoading />;
|
||||||
|
} else if (contentError) {
|
||||||
|
Content = <ContentError error={contentError} />;
|
||||||
|
} else if (items.length <= 0) {
|
||||||
|
Content = (
|
||||||
|
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
||||||
);
|
);
|
||||||
const emptyContentTitle = i18n._(t`No ${pluralizedItemName} Found `);
|
} else {
|
||||||
|
Content = (
|
||||||
let Content;
|
<>
|
||||||
if (hasContentLoading && items.length <= 0) {
|
{hasContentLoading && <LoadingSpinner />}
|
||||||
Content = <ContentLoading />;
|
|
||||||
} else if (contentError) {
|
|
||||||
Content = <ContentError error={contentError} />;
|
|
||||||
} else if (items.length <= 0) {
|
|
||||||
Content = (
|
|
||||||
<ContentEmpty title={emptyContentTitle} message={emptyContentMessage} />
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Content = (
|
|
||||||
<DataList
|
<DataList
|
||||||
aria-label={dataListLabel}
|
aria-label={dataListLabel}
|
||||||
onSelectDataListItem={id => this.handleListItemSelect(id)}
|
onSelectDataListItem={id => handleListItemSelect(id)}
|
||||||
>
|
>
|
||||||
{items.map(renderItem)}
|
{items.map(renderItem)}
|
||||||
</DataList>
|
</DataList>
|
||||||
);
|
</>
|
||||||
}
|
|
||||||
|
|
||||||
const ToolbarPagination = (
|
|
||||||
<Pagination
|
|
||||||
isCompact
|
|
||||||
dropDirection="down"
|
|
||||||
itemCount={itemCount}
|
|
||||||
page={queryParams.page || 1}
|
|
||||||
perPage={queryParams.page_size}
|
|
||||||
perPageOptions={
|
|
||||||
showPageSizeOptions
|
|
||||||
? [
|
|
||||||
{ title: '5', value: 5 },
|
|
||||||
{ title: '10', value: 10 },
|
|
||||||
{ title: '20', value: 20 },
|
|
||||||
{ title: '50', value: 50 },
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
onSetPage={this.handleSetPage}
|
|
||||||
onPerPageSelect={this.handleSetPageSize}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<ListHeader
|
|
||||||
itemCount={itemCount}
|
|
||||||
renderToolbar={renderToolbar}
|
|
||||||
emptyStateControls={emptyStateControls}
|
|
||||||
searchColumns={searchColumns}
|
|
||||||
sortColumns={sortColumns}
|
|
||||||
searchableKeys={toolbarSearchableKeys}
|
|
||||||
relatedSearchableKeys={toolbarRelatedSearchableKeys}
|
|
||||||
qsConfig={qsConfig}
|
|
||||||
pagination={ToolbarPagination}
|
|
||||||
/>
|
|
||||||
{Content}
|
|
||||||
{items.length ? (
|
|
||||||
<Pagination
|
|
||||||
variant="bottom"
|
|
||||||
itemCount={itemCount}
|
|
||||||
page={queryParams.page || 1}
|
|
||||||
perPage={queryParams.page_size}
|
|
||||||
perPageOptions={
|
|
||||||
showPageSizeOptions
|
|
||||||
? [
|
|
||||||
{ title: '5', value: 5 },
|
|
||||||
{ title: '10', value: 10 },
|
|
||||||
{ title: '20', value: 20 },
|
|
||||||
{ title: '50', value: 50 },
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
onSetPage={this.handleSetPage}
|
|
||||||
onPerPageSelect={this.handleSetPageSize}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ToolbarPagination = (
|
||||||
|
<Pagination
|
||||||
|
isCompact
|
||||||
|
dropDirection="down"
|
||||||
|
itemCount={itemCount}
|
||||||
|
page={queryParams.page || 1}
|
||||||
|
perPage={queryParams.page_size}
|
||||||
|
perPageOptions={
|
||||||
|
showPageSizeOptions
|
||||||
|
? [
|
||||||
|
{ title: '5', value: 5 },
|
||||||
|
{ title: '10', value: 10 },
|
||||||
|
{ title: '20', value: 20 },
|
||||||
|
{ title: '50', value: 50 },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSetPage={handleSetPage}
|
||||||
|
onPerPageSelect={handleSetPageSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<ListHeader
|
||||||
|
itemCount={itemCount}
|
||||||
|
renderToolbar={renderToolbar}
|
||||||
|
emptyStateControls={emptyStateControls}
|
||||||
|
searchColumns={searchColumns}
|
||||||
|
sortColumns={sortColumns}
|
||||||
|
searchableKeys={toolbarSearchableKeys}
|
||||||
|
relatedSearchableKeys={toolbarRelatedSearchableKeys}
|
||||||
|
qsConfig={qsConfig}
|
||||||
|
pagination={ToolbarPagination}
|
||||||
|
/>
|
||||||
|
{Content}
|
||||||
|
{items.length ? (
|
||||||
|
<Pagination
|
||||||
|
variant="bottom"
|
||||||
|
itemCount={itemCount}
|
||||||
|
page={queryParams.page || 1}
|
||||||
|
perPage={queryParams.page_size}
|
||||||
|
perPageOptions={
|
||||||
|
showPageSizeOptions
|
||||||
|
? [
|
||||||
|
{ title: '5', value: 5 },
|
||||||
|
{ title: '10', value: 10 },
|
||||||
|
{ title: '20', value: 20 },
|
||||||
|
{ title: '50', value: 50 },
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onSetPage={handleSetPage}
|
||||||
|
onPerPageSelect={handleSetPageSize}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Item = PropTypes.shape({
|
const Item = PropTypes.shape({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import ContentError from '../ContentError';
|
|||||||
import ContentLoading from '../ContentLoading';
|
import ContentLoading from '../ContentLoading';
|
||||||
import Pagination from '../Pagination';
|
import Pagination from '../Pagination';
|
||||||
import DataListToolbar from '../DataListToolbar';
|
import DataListToolbar from '../DataListToolbar';
|
||||||
|
import LoadingSpinner from '../LoadingSpinner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
encodeNonDefaultQueryString,
|
encodeNonDefaultQueryString,
|
||||||
@@ -82,10 +83,13 @@ function PaginatedTable({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Content = (
|
Content = (
|
||||||
<TableComposable aria-label={dataListLabel}>
|
<>
|
||||||
{headerRow}
|
{hasContentLoading && <LoadingSpinner />}
|
||||||
<Tbody>{items.map(renderRow)}</Tbody>
|
<TableComposable aria-label={dataListLabel}>
|
||||||
</TableComposable>
|
{headerRow}
|
||||||
|
<Tbody>{items.map(renderRow)}</Tbody>
|
||||||
|
</TableComposable>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function ResourceAccessList({ i18n, apiModel, resource }) {
|
|||||||
fetchAccessRecords();
|
fetchAccessRecords();
|
||||||
}}
|
}}
|
||||||
roles={resource.summary_fields.object_roles}
|
roles={resource.summary_fields.object_roles}
|
||||||
|
resource={resource}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showDeleteModal && (
|
{showDeleteModal && (
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function ScheduleList({
|
|||||||
scheduleActions.data.actions?.GET || {}
|
scheduleActions.data.actions?.GET || {}
|
||||||
).filter(key => scheduleActions.data.actions?.GET[key].filterable),
|
).filter(key => scheduleActions.data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [location, loadSchedules, loadScheduleOptions]),
|
}, [location.search, loadSchedules, loadScheduleOptions]),
|
||||||
{
|
{
|
||||||
schedules: [],
|
schedules: [],
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ const Description = styled.p`
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
function SelectableCard({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
onClick,
|
||||||
|
isSelected,
|
||||||
|
dataCy,
|
||||||
|
ariaLabel,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectableItem
|
<SelectableItem
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -40,6 +47,7 @@ function SelectableCard({ label, description, onClick, isSelected, dataCy }) {
|
|||||||
tabIndex="0"
|
tabIndex="0"
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
|
aria-label={ariaLabel}
|
||||||
>
|
>
|
||||||
<Indicator isSelected={isSelected} />
|
<Indicator isSelected={isSelected} />
|
||||||
<Contents>
|
<Contents>
|
||||||
@@ -55,12 +63,14 @@ SelectableCard.propTypes = {
|
|||||||
description: PropTypes.string,
|
description: PropTypes.string,
|
||||||
onClick: PropTypes.func.isRequired,
|
onClick: PropTypes.func.isRequired,
|
||||||
isSelected: PropTypes.bool,
|
isSelected: PropTypes.bool,
|
||||||
|
ariaLabel: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
SelectableCard.defaultProps = {
|
SelectableCard.defaultProps = {
|
||||||
label: '',
|
label: '',
|
||||||
description: '',
|
description: '',
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
|
ariaLabel: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SelectableCard;
|
export default SelectableCard;
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { Button } from '@patternfly/react-core';
|
|||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
import { Detail, DetailList } from '../../../components/DetailList';
|
import {
|
||||||
|
Detail,
|
||||||
|
DetailList,
|
||||||
|
UserDateDetail,
|
||||||
|
} from '../../../components/DetailList';
|
||||||
import { ApplicationsAPI } from '../../../api';
|
import { ApplicationsAPI } from '../../../api';
|
||||||
import DeleteButton from '../../../components/DeleteButton';
|
import DeleteButton from '../../../components/DeleteButton';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
@@ -98,6 +102,11 @@ function ApplicationDetails({
|
|||||||
value={getClientType(application.client_type)}
|
value={getClientType(application.client_type)}
|
||||||
dataCy="app-detail-client-type"
|
dataCy="app-detail-client-type"
|
||||||
/>
|
/>
|
||||||
|
<UserDateDetail label={i18n._(t`Created`)} date={application.created} />
|
||||||
|
<UserDateDetail
|
||||||
|
label={i18n._(t`Last Modified`)}
|
||||||
|
date={application.modified}
|
||||||
|
/>
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{application.summary_fields.user_capabilities &&
|
{application.summary_fields.user_capabilities &&
|
||||||
|
|||||||
@@ -56,15 +56,12 @@ function Credential({ i18n, setBreadcrumb }) {
|
|||||||
id: 99,
|
id: 99,
|
||||||
},
|
},
|
||||||
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
|
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
|
||||||
];
|
{
|
||||||
|
|
||||||
if (credential && credential.organization) {
|
|
||||||
tabsArray.push({
|
|
||||||
name: i18n._(t`Access`),
|
name: i18n._(t`Access`),
|
||||||
link: `/credentials/${id}/access`,
|
link: `/credentials/${id}/access`,
|
||||||
id: 1,
|
id: 1,
|
||||||
});
|
},
|
||||||
}
|
];
|
||||||
|
|
||||||
let showCardHeader = true;
|
let showCardHeader = true;
|
||||||
|
|
||||||
@@ -108,14 +105,12 @@ function Credential({ i18n, setBreadcrumb }) {
|
|||||||
<Route key="edit" path="/credentials/:id/edit">
|
<Route key="edit" path="/credentials/:id/edit">
|
||||||
<CredentialEdit credential={credential} />
|
<CredentialEdit credential={credential} />
|
||||||
</Route>,
|
</Route>,
|
||||||
credential.organization && (
|
<Route key="access" path="/credentials/:id/access">
|
||||||
<Route key="access" path="/credentials/:id/access">
|
<ResourceAccessList
|
||||||
<ResourceAccessList
|
resource={credential}
|
||||||
resource={credential}
|
apiModel={CredentialsAPI}
|
||||||
apiModel={CredentialsAPI}
|
/>
|
||||||
/>
|
</Route>,
|
||||||
</Route>
|
|
||||||
),
|
|
||||||
<Route key="not-found" path="*">
|
<Route key="not-found" path="*">
|
||||||
{!hasContentLoading && (
|
{!hasContentLoading && (
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('<Credential />', () => {
|
|||||||
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 2);
|
await waitForElement(wrapper, '.pf-c-tabs__item', el => el.length === 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initially renders org-based credential succesfully', async () => {
|
test('initially renders org-based credential succesfully', async () => {
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function CredentialDetail({ i18n, credential }) {
|
|||||||
{}
|
{}
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}, [credentialId, credential_type]),
|
}, [credentialId, credential_type.id]),
|
||||||
{
|
{
|
||||||
fields: [],
|
fields: [],
|
||||||
managedByTower: true,
|
managedByTower: true,
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ function CredentialList({ i18n }) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { credentials, credentialCount, actions },
|
result: {
|
||||||
|
credentials,
|
||||||
|
credentialCount,
|
||||||
|
actions,
|
||||||
|
relatedSearchableKeys,
|
||||||
|
searchableKeys,
|
||||||
|
},
|
||||||
error: contentError,
|
error: contentError,
|
||||||
isLoading,
|
isLoading,
|
||||||
request: fetchCredentials,
|
request: fetchCredentials,
|
||||||
@@ -37,16 +43,29 @@ function CredentialList({ i18n }) {
|
|||||||
CredentialsAPI.read(params),
|
CredentialsAPI.read(params),
|
||||||
CredentialsAPI.readOptions(),
|
CredentialsAPI.readOptions(),
|
||||||
]);
|
]);
|
||||||
|
const searchKeys = Object.keys(
|
||||||
|
credActions.data.actions?.GET || {}
|
||||||
|
).filter(key => credActions.data.actions?.GET[key].filterable);
|
||||||
|
const item = searchKeys.indexOf('type');
|
||||||
|
if (item) {
|
||||||
|
searchKeys[item] = 'credential_type__kind';
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
credentials: creds.data.results,
|
credentials: creds.data.results,
|
||||||
credentialCount: creds.data.count,
|
credentialCount: creds.data.count,
|
||||||
actions: credActions.data.actions,
|
actions: credActions.data.actions,
|
||||||
|
relatedSearchableKeys: (
|
||||||
|
credActions?.data?.related_search_fields || []
|
||||||
|
).map(val => val.slice(0, -8)),
|
||||||
|
searchableKeys: searchKeys,
|
||||||
};
|
};
|
||||||
}, [location]),
|
}, [location]),
|
||||||
{
|
{
|
||||||
credentials: [],
|
credentials: [],
|
||||||
credentialCount: 0,
|
credentialCount: 0,
|
||||||
actions: {},
|
actions: {},
|
||||||
|
relatedSearchableKeys: [],
|
||||||
|
searchableKeys: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,6 +121,8 @@ function CredentialList({ i18n }) {
|
|||||||
itemCount={credentialCount}
|
itemCount={credentialCount}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
|
toolbarSearchableKeys={searchableKeys}
|
||||||
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
|
|||||||
@@ -275,6 +275,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"help_text": "OpenStack domains define administrative boundaries. It is only needed for Keystone v3 authentication URLs. Refer to Ansible Tower documentation for common scenarios."
|
"help_text": "OpenStack domains define administrative boundaries. It is only needed for Keystone v3 authentication URLs. Refer to Ansible Tower documentation for common scenarios."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "project_region_name",
|
||||||
|
"label": "Region Name",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "verify_ssl",
|
"id": "verify_ssl",
|
||||||
"label": "Verify SSL",
|
"label": "Verify SSL",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import DatalistToolbar from '../../../components/DataListToolbar';
|
|||||||
|
|
||||||
import CredentialTypeListItem from './CredentialTypeListItem';
|
import CredentialTypeListItem from './CredentialTypeListItem';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('credential_type', {
|
const QS_CONFIG = getQSConfig('credential-type', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
managed_by_tower: false,
|
managed_by_tower: false,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import useRequest from '../../util/useRequest';
|
|||||||
import { DashboardAPI } from '../../api';
|
import { DashboardAPI } from '../../api';
|
||||||
import Breadcrumbs from '../../components/Breadcrumbs';
|
import Breadcrumbs from '../../components/Breadcrumbs';
|
||||||
import JobList from '../../components/JobList';
|
import JobList from '../../components/JobList';
|
||||||
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import LineChart from './shared/LineChart';
|
import LineChart from './shared/LineChart';
|
||||||
import Count from './shared/Count';
|
import Count from './shared/Count';
|
||||||
import DashboardTemplateList from './shared/DashboardTemplateList';
|
import DashboardTemplateList from './shared/DashboardTemplateList';
|
||||||
@@ -62,6 +62,7 @@ function Dashboard({ i18n }) {
|
|||||||
const [activeTabId, setActiveTabId] = useState(0);
|
const [activeTabId, setActiveTabId] = useState(0);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
isLoading,
|
||||||
result: { jobGraphData, countData },
|
result: { jobGraphData, countData },
|
||||||
request: fetchDashboardGraph,
|
request: fetchDashboardGraph,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
@@ -105,7 +106,15 @@ function Dashboard({ i18n }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDashboardGraph();
|
fetchDashboardGraph();
|
||||||
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
|
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageSection>
|
||||||
|
<Card>
|
||||||
|
<ContentLoading />
|
||||||
|
</Card>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
|
<Breadcrumbs breadcrumbConfig={{ '/home': i18n._(t`Dashboard`) }} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
@@ -20,29 +20,22 @@ import HostDetail from './HostDetail';
|
|||||||
import HostEdit from './HostEdit';
|
import HostEdit from './HostEdit';
|
||||||
import HostGroups from './HostGroups';
|
import HostGroups from './HostGroups';
|
||||||
import { HostsAPI } from '../../api';
|
import { HostsAPI } from '../../api';
|
||||||
|
import useRequest from '../../util/useRequest';
|
||||||
|
|
||||||
function Host({ i18n, setBreadcrumb }) {
|
function Host({ i18n, setBreadcrumb }) {
|
||||||
const [host, setHost] = useState(null);
|
|
||||||
const [contentError, setContentError] = useState(null);
|
|
||||||
const [hasContentLoading, setHasContentLoading] = useState(true);
|
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const match = useRouteMatch('/hosts/:id');
|
const match = useRouteMatch('/hosts/:id');
|
||||||
|
const { error, isLoading, result: host, request: fetchHost } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await HostsAPI.readDetail(match.params.id);
|
||||||
|
setBreadcrumb(data);
|
||||||
|
return data;
|
||||||
|
}, [match.params.id, setBreadcrumb])
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
fetchHost();
|
||||||
setContentError(null);
|
}, [fetchHost, location]);
|
||||||
try {
|
|
||||||
const { data } = await HostsAPI.readDetail(match.params.id);
|
|
||||||
setHost(data);
|
|
||||||
setBreadcrumb(data);
|
|
||||||
} catch (error) {
|
|
||||||
setContentError(error);
|
|
||||||
} finally {
|
|
||||||
setHasContentLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [match.params.id, location, setBreadcrumb]);
|
|
||||||
|
|
||||||
const tabsArray = [
|
const tabsArray = [
|
||||||
{
|
{
|
||||||
@@ -77,7 +70,7 @@ function Host({ i18n, setBreadcrumb }) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -87,12 +80,12 @@ function Host({ i18n, setBreadcrumb }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentError) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<ContentError error={contentError}>
|
<ContentError error={error}>
|
||||||
{contentError?.response?.status === 404 && (
|
{error?.response?.status === 404 && (
|
||||||
<span>
|
<span>
|
||||||
{i18n._(t`Host not found.`)}{' '}
|
{i18n._(t`Host not found.`)}{' '}
|
||||||
<Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link>
|
<Link to="/hosts">{i18n._(t`View all Hosts.`)}</Link>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Route } from 'react-router-dom';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { HostsAPI } from '../../api';
|
import { HostsAPI } from '../../api';
|
||||||
@@ -28,7 +29,11 @@ describe('<Host />', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<Host setBreadcrumb={() => {}} />);
|
wrapper = mountWithContexts(
|
||||||
|
<Route path="/hosts/:id/details">
|
||||||
|
<Host setBreadcrumb={() => {}} />
|
||||||
|
</Route>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
||||||
"HOME": "/var/lib/awx",
|
"HOME": "/var/lib/awx",
|
||||||
"LANG": "\"en-us\"",
|
"LANG": "\"en-us\"",
|
||||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
"SHLVL": "4",
|
"SHLVL": "4",
|
||||||
"JOB_ID": "13",
|
"JOB_ID": "13",
|
||||||
"LC_ALL": "en_US.UTF-8",
|
"LC_ALL": "en_US.UTF-8",
|
||||||
@@ -96,9 +96,9 @@
|
|||||||
"SDB_PORT": "7899",
|
"SDB_PORT": "7899",
|
||||||
"MAKEFLAGS": "w",
|
"MAKEFLAGS": "w",
|
||||||
"MAKELEVEL": "2",
|
"MAKELEVEL": "2",
|
||||||
"PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||||
"CURRENT_UID": "501",
|
"CURRENT_UID": "501",
|
||||||
"VIRTUAL_ENV": "/venv/ansible",
|
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||||
"INVENTORY_ID": "1",
|
"INVENTORY_ID": "1",
|
||||||
"MAX_EVENT_RES": "700000",
|
"MAX_EVENT_RES": "700000",
|
||||||
"PROOT_TMP_DIR": "/tmp",
|
"PROOT_TMP_DIR": "/tmp",
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||||
"AWX_GROUP_QUEUES": "tower",
|
"AWX_GROUP_QUEUES": "tower",
|
||||||
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
||||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||||
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
||||||
"RUNNER_OMIT_EVENTS": "False",
|
"RUNNER_OMIT_EVENTS": "False",
|
||||||
"SUPERVISOR_ENABLED": "1",
|
"SUPERVISOR_ENABLED": "1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||||
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
||||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
||||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||||
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ describe('<ContainerGroupEdit/>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('called InstanceGroupsAPI.readOptions', async () => {
|
test('called InstanceGroupsAPI.readOptions', async () => {
|
||||||
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalledTimes(1);
|
expect(InstanceGroupsAPI.readOptions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleCancel returns the user to container group detail', async () => {
|
test('handleCancel returns the user to container group detail', async () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import AddDropDownButton from '../../../components/AddDropDownButton';
|
|||||||
|
|
||||||
import InstanceGroupListItem from './InstanceGroupListItem';
|
import InstanceGroupListItem from './InstanceGroupListItem';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('instance_group', {
|
const QS_CONFIG = getQSConfig('instance-group', {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('InventorySourceDetail', () => {
|
|||||||
assertDetail(wrapper, 'Description', 'mock description');
|
assertDetail(wrapper, 'Description', 'mock description');
|
||||||
assertDetail(wrapper, 'Source', 'Sourced from a Project');
|
assertDetail(wrapper, 'Source', 'Sourced from a Project');
|
||||||
assertDetail(wrapper, 'Organization', 'Mock Org');
|
assertDetail(wrapper, 'Organization', 'Mock Org');
|
||||||
assertDetail(wrapper, 'Ansible environment', '/venv/custom');
|
assertDetail(wrapper, 'Ansible environment', '/var/lib/awx/venv/custom');
|
||||||
assertDetail(wrapper, 'Project', 'Mock Project');
|
assertDetail(wrapper, 'Project', 'Mock Project');
|
||||||
assertDetail(wrapper, 'Inventory file', 'foo');
|
assertDetail(wrapper, 'Inventory file', 'foo');
|
||||||
assertDetail(wrapper, 'Verbosity', '2 (Debug)');
|
assertDetail(wrapper, 'Verbosity', '2 (Debug)');
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
|
|||||||
const [venvField] = useField('custom_virtualenv');
|
const [venvField] = useField('custom_virtualenv');
|
||||||
const defaultVenv = {
|
const defaultVenv = {
|
||||||
label: i18n._(t`Use Default Ansible Environment`),
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
value: '/venv/ansible/',
|
value: '/var/lib/awx/venv/ansible/',
|
||||||
key: 'default',
|
key: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
"PWD": "/tmp/awx_13_r1ffeqze/project",
|
||||||
"HOME": "/var/lib/awx",
|
"HOME": "/var/lib/awx",
|
||||||
"LANG": "\"en-us\"",
|
"LANG": "\"en-us\"",
|
||||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
"SHLVL": "4",
|
"SHLVL": "4",
|
||||||
"JOB_ID": "13",
|
"JOB_ID": "13",
|
||||||
"LC_ALL": "en_US.UTF-8",
|
"LC_ALL": "en_US.UTF-8",
|
||||||
@@ -96,9 +96,9 @@
|
|||||||
"SDB_PORT": "7899",
|
"SDB_PORT": "7899",
|
||||||
"MAKEFLAGS": "w",
|
"MAKEFLAGS": "w",
|
||||||
"MAKELEVEL": "2",
|
"MAKELEVEL": "2",
|
||||||
"PYTHONPATH": "/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python3.6/site-packages:/awx_devel/awx/lib:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||||
"CURRENT_UID": "501",
|
"CURRENT_UID": "501",
|
||||||
"VIRTUAL_ENV": "/venv/ansible",
|
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||||
"INVENTORY_ID": "1",
|
"INVENTORY_ID": "1",
|
||||||
"MAX_EVENT_RES": "700000",
|
"MAX_EVENT_RES": "700000",
|
||||||
"PROOT_TMP_DIR": "/tmp",
|
"PROOT_TMP_DIR": "/tmp",
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||||
"AWX_GROUP_QUEUES": "tower",
|
"AWX_GROUP_QUEUES": "tower",
|
||||||
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
"PROJECT_REVISION": "9e2cd25bfb26ba82f40cf31276e1942bf38b3a30",
|
||||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||||
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
"ANSIBLE_ROLES_PATH": "/tmp/awx_13_r1ffeqze/requirements_roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles",
|
||||||
"RUNNER_OMIT_EVENTS": "False",
|
"RUNNER_OMIT_EVENTS": "False",
|
||||||
"SUPERVISOR_ENABLED": "1",
|
"SUPERVISOR_ENABLED": "1",
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||||
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
"ANSIBLE_STDOUT_CALLBACK": "awx_display",
|
||||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||||
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
"ANSIBLE_CALLBACK_PLUGINS": "/awx_devel/awx/plugins/callback:/var/lib/awx/venv/awx/lib/python3.6/site-packages/ansible_runner/callbacks",
|
||||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/awx_13_r1ffeqze/requirements_collections:~/.ansible/collections:/usr/share/ansible/collections",
|
||||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||||
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
"RUNNER_ONLY_FAILED_EVENTS": "False",
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
"credential": 8,
|
"credential": 8,
|
||||||
"overwrite":true,
|
"overwrite":true,
|
||||||
"overwrite_vars":true,
|
"overwrite_vars":true,
|
||||||
"custom_virtualenv":"/venv/custom",
|
"custom_virtualenv":"/var/lib/awx/venv/custom",
|
||||||
"timeout":0,
|
"timeout":0,
|
||||||
"verbosity":2,
|
"verbosity":2,
|
||||||
"last_job_run":null,
|
"last_job_run":null,
|
||||||
|
|||||||
@@ -29,10 +29,18 @@ function Job({ i18n, setBreadcrumb }) {
|
|||||||
const { isLoading, error, request: fetchJob, result } = useRequest(
|
const { isLoading, error, request: fetchJob, result } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const { data } = await JobsAPI.readDetail(id, type);
|
const { data } = await JobsAPI.readDetail(id, type);
|
||||||
|
if (
|
||||||
|
data?.summary_fields?.credentials?.find(cred => cred.kind === 'vault')
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
data: { results },
|
||||||
|
} = await JobsAPI.readCredentials(data.id, type);
|
||||||
|
|
||||||
|
data.summary_fields.credentials = results;
|
||||||
|
}
|
||||||
setBreadcrumb(data);
|
setBreadcrumb(data);
|
||||||
return data;
|
return data;
|
||||||
}, [id, type, setBreadcrumb]),
|
}, [id, type, setBreadcrumb])
|
||||||
null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { Button, Chip, Label } from '@patternfly/react-core';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import { DetailList, Detail } from '../../../components/DetailList';
|
import {
|
||||||
|
DetailList,
|
||||||
|
Detail,
|
||||||
|
UserDateDetail,
|
||||||
|
} from '../../../components/DetailList';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
import ChipGroup from '../../../components/ChipGroup';
|
import ChipGroup from '../../../components/ChipGroup';
|
||||||
import CredentialChip from '../../../components/CredentialChip';
|
import CredentialChip from '../../../components/CredentialChip';
|
||||||
@@ -80,6 +84,7 @@ const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
|||||||
|
|
||||||
function JobDetail({ job, i18n }) {
|
function JobDetail({ job, i18n }) {
|
||||||
const {
|
const {
|
||||||
|
created_by,
|
||||||
credential,
|
credential,
|
||||||
credentials,
|
credentials,
|
||||||
instance_group: instanceGroup,
|
instance_group: instanceGroup,
|
||||||
@@ -289,6 +294,12 @@ function JobDetail({ job, i18n }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<UserDateDetail
|
||||||
|
label={i18n._(t`Created`)}
|
||||||
|
date={job.created}
|
||||||
|
user={created_by}
|
||||||
|
/>
|
||||||
|
<UserDateDetail label={i18n._(t`Last Modified`)} date={job.modified} />
|
||||||
</DetailList>
|
</DetailList>
|
||||||
{job.extra_vars && (
|
{job.extra_vars && (
|
||||||
<VariablesInput
|
<VariablesInput
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
"started": "2019-08-08T19:24:18.329589Z",
|
"started": "2019-08-08T19:24:18.329589Z",
|
||||||
"finished": "2019-08-08T19:24:50.119995Z",
|
"finished": "2019-08-08T19:24:50.119995Z",
|
||||||
"elapsed": 31.79,
|
"elapsed": 31.79,
|
||||||
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/venv/ansible\", \"/venv/ansible\", \"--ro-bind\", \"/venv/awx\", \"/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
|
"job_args": "[\"bwrap\", \"--unshare-pid\", \"--dev-bind\", \"/\", \"/\", \"--proc\", \"/proc\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpvsg8ly2y\", \"/etc/ssh\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq_grmdym\", \"/projects\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpfq8ea2z6\", \"/tmp\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpq6v4y_tt\", \"/var/lib/awx\", \"--bind\", \"/tmp/ansible_runner_pi_pzufy15c/ansible_runner_pi_r_aeukpy/tmpupj_jhhb\", \"/var/log\", \"--ro-bind\", \"/var/lib/awx/venv/ansible\", \"/var/lib/awx/venv/ansible\", \"--ro-bind\", \"/var/lib/awx/venv/awx\", \"/var/lib/awx/venv/awx\", \"--bind\", \"/projects/_6__demo_project\", \"/projects/_6__demo_project\", \"--bind\", \"/tmp/awx_2_a4b1afiw\", \"/tmp/awx_2_a4b1afiw\", \"--chdir\", \"/projects/_6__demo_project\", \"ansible-playbook\", \"-u\", \"admin\", \"-i\", \"/tmp/awx_2_a4b1afiw/tmppb57i4_e\", \"-e\", \"@/tmp/awx_2_a4b1afiw/env/extravars\", \"chatty_tasks.yml\"]",
|
||||||
"job_cwd": "/projects/_6__demo_project",
|
"job_cwd": "/projects/_6__demo_project",
|
||||||
"job_env": {
|
"job_env": {
|
||||||
"HOSTNAME": "awx",
|
"HOSTNAME": "awx",
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
"LC_ALL": "en_US.UTF-8",
|
"LC_ALL": "en_US.UTF-8",
|
||||||
"SDB_HOST": "0.0.0.0",
|
"SDB_HOST": "0.0.0.0",
|
||||||
"MAKELEVEL": "2",
|
"MAKELEVEL": "2",
|
||||||
"VIRTUAL_ENV": "/venv/ansible",
|
"VIRTUAL_ENV": "/var/lib/awx/venv/ansible",
|
||||||
"MFLAGS": "-w",
|
"MFLAGS": "-w",
|
||||||
"PATH": "/venv/ansible/bin:/venv/awx/bin:/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
"PATH": "/var/lib/awx/venv/ansible/bin:/var/lib/awx/venv/awx/bin:/var/lib/awx/venv/awx/bin:/usr/local/n/versions/node/10.15.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||||
"SUPERVISOR_GROUP_NAME": "tower-processes",
|
"SUPERVISOR_GROUP_NAME": "tower-processes",
|
||||||
"PWD": "/awx_devel",
|
"PWD": "/awx_devel",
|
||||||
"LANG": "\"en-us\"",
|
"LANG": "\"en-us\"",
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
|
"SUPERVISOR_SERVER_URL": "unix:///tmp/supervisor.sock",
|
||||||
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
"SUPERVISOR_PROCESS_NAME": "awx-dispatcher",
|
||||||
"CURRENT_UID": "501",
|
"CURRENT_UID": "501",
|
||||||
"_": "/venv/awx/bin/python3",
|
"_": "/var/lib/awx/venv/awx/bin/python3",
|
||||||
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
"DJANGO_SETTINGS_MODULE": "awx.settings.development",
|
||||||
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
|
"DJANGO_LIVE_TEST_SERVER_ADDRESS": "localhost:9013-9199",
|
||||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
"ANSIBLE_HOST_KEY_CHECKING": "False",
|
||||||
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
|
"ANSIBLE_INVENTORY_UNPARSED_FAILED": "True",
|
||||||
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
|
"ANSIBLE_PARAMIKO_RECORD_HOST_KEYS": "False",
|
||||||
"ANSIBLE_VENV_PATH": "/venv/ansible",
|
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||||
"PROOT_TMP_DIR": "/tmp",
|
"PROOT_TMP_DIR": "/tmp",
|
||||||
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
|
"AWX_PRIVATE_DATA_DIR": "/tmp/awx_2_a4b1afiw",
|
||||||
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
|
"ANSIBLE_COLLECTIONS_PATHS": "/tmp/collections",
|
||||||
"PYTHONPATH": "/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
|
"PYTHONPATH": "/var/lib/awx/venv/ansible/lib/python2.7/site-packages:/awx_devel/awx/lib:",
|
||||||
"JOB_ID": "2",
|
"JOB_ID": "2",
|
||||||
"INVENTORY_ID": "1",
|
"INVENTORY_ID": "1",
|
||||||
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
|
"PROJECT_REVISION": "23f070aad8e2da131d97ea98b42b553ccf0b0b82",
|
||||||
@@ -184,5 +184,5 @@
|
|||||||
"play_count": 1,
|
"play_count": 1,
|
||||||
"task_count": 1
|
"task_count": 1
|
||||||
},
|
},
|
||||||
"custom_virtualenv": "/venv/ansible"
|
"custom_virtualenv": "/var/lib/awx/venv/ansible"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ArrayDetail,
|
ArrayDetail,
|
||||||
DetailList,
|
DetailList,
|
||||||
DeletedDetail,
|
DeletedDetail,
|
||||||
|
UserDateDetail,
|
||||||
} from '../../../components/DetailList';
|
} from '../../../components/DetailList';
|
||||||
import CodeDetail from '../../../components/DetailList/CodeDetail';
|
import CodeDetail from '../../../components/DetailList/CodeDetail';
|
||||||
import DeleteButton from '../../../components/DeleteButton';
|
import DeleteButton from '../../../components/DeleteButton';
|
||||||
@@ -23,6 +24,8 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
|
|||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
created,
|
||||||
|
modified,
|
||||||
notification_configuration: configuration,
|
notification_configuration: configuration,
|
||||||
summary_fields,
|
summary_fields,
|
||||||
messages,
|
messages,
|
||||||
@@ -324,6 +327,16 @@ function NotificationTemplateDetail({ i18n, template, defaultMessages }) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<UserDateDetail
|
||||||
|
label={i18n._(t`Created`)}
|
||||||
|
date={created}
|
||||||
|
user={summary_fields?.created_by}
|
||||||
|
/>
|
||||||
|
<UserDateDetail
|
||||||
|
label={i18n._(t`Last Modified`)}
|
||||||
|
date={modified}
|
||||||
|
user={summary_fields?.modified_by}
|
||||||
|
/>
|
||||||
{hasCustomMessages(messages, typeMessageDefaults) && (
|
{hasCustomMessages(messages, typeMessageDefaults) && (
|
||||||
<CustomMessageDetails
|
<CustomMessageDetails
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe('<OrganizationAdd />', () => {
|
|||||||
.find('FormSelectOption')
|
.find('FormSelectOption')
|
||||||
.first()
|
.first()
|
||||||
.prop('value')
|
.prop('value')
|
||||||
).toEqual('/venv/ansible/');
|
).toEqual('/var/lib/awx/venv/ansible/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
|
test('AnsibleSelect component does not render if there are 0 virtual environments', async () => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function OrganizationFormFields({ i18n, instanceGroups, setInstanceGroups }) {
|
|||||||
|
|
||||||
const defaultVenv = {
|
const defaultVenv = {
|
||||||
label: i18n._(t`Use Default Ansible Environment`),
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
value: '/venv/ansible/',
|
value: '/var/lib/awx/venv/ansible/',
|
||||||
key: 'default',
|
key: 'default',
|
||||||
};
|
};
|
||||||
const { custom_virtualenvs } = useContext(ConfigContext);
|
const { custom_virtualenvs } = useContext(ConfigContext);
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ describe('<OrganizationForm />', () => {
|
|||||||
.find('FormSelectOption')
|
.find('FormSelectOption')
|
||||||
.first()
|
.first()
|
||||||
.prop('value')
|
.prop('value')
|
||||||
).toEqual('/venv/ansible/');
|
).toEqual('/var/lib/awx/venv/ansible/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('onSubmit associates and disassociates instance groups', async () => {
|
test('onSubmit associates and disassociates instance groups', async () => {
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ function Project({ i18n, setBreadcrumb }) {
|
|||||||
role_level: 'notification_admin_role',
|
role_level: 'notification_admin_role',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (data.summary_fields.credentials) {
|
||||||
|
const params = {
|
||||||
|
page: 1,
|
||||||
|
page_size: 200,
|
||||||
|
order_by: 'name',
|
||||||
|
};
|
||||||
|
const {
|
||||||
|
data: { results },
|
||||||
|
} = await ProjectsAPI.readCredentials(data.id, params);
|
||||||
|
|
||||||
|
data.summary_fields.credentials = results;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
project: data,
|
project: data,
|
||||||
isNotifAdmin: notifAdminRes.data.results.length > 0,
|
isNotifAdmin: notifAdminRes.data.results.length > 0,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe('<ProjectAdd />', () => {
|
|||||||
scm_update_on_launch: true,
|
scm_update_on_launch: true,
|
||||||
scm_update_cache_timeout: 3,
|
scm_update_cache_timeout: 3,
|
||||||
allow_override: false,
|
allow_override: false,
|
||||||
custom_virtualenv: '/venv/custom-env',
|
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectOptionsResolve = {
|
const projectOptionsResolve = {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import CredentialChip from '../../../components/CredentialChip';
|
|||||||
import { ProjectsAPI } from '../../../api';
|
import { ProjectsAPI } from '../../../api';
|
||||||
import { toTitleCase } from '../../../util/strings';
|
import { toTitleCase } from '../../../util/strings';
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
|
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
||||||
|
|
||||||
function ProjectDetail({ project, i18n }) {
|
function ProjectDetail({ project, i18n }) {
|
||||||
const {
|
const {
|
||||||
@@ -148,27 +149,28 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
/>
|
/>
|
||||||
</DetailList>
|
</DetailList>
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{summary_fields.user_capabilities &&
|
{summary_fields.user_capabilities?.edit && (
|
||||||
summary_fields.user_capabilities.edit && (
|
<Button
|
||||||
<Button
|
aria-label={i18n._(t`edit`)}
|
||||||
aria-label={i18n._(t`edit`)}
|
component={Link}
|
||||||
component={Link}
|
to={`/projects/${id}/edit`}
|
||||||
to={`/projects/${id}/edit`}
|
>
|
||||||
>
|
{i18n._(t`Edit`)}
|
||||||
{i18n._(t`Edit`)}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
{summary_fields.user_capabilities?.start && (
|
||||||
{summary_fields.user_capabilities &&
|
<ProjectSyncButton projectId={project.id} />
|
||||||
summary_fields.user_capabilities.delete && (
|
)}
|
||||||
<DeleteButton
|
{summary_fields.user_capabilities?.delete && (
|
||||||
name={name}
|
<DeleteButton
|
||||||
modalTitle={i18n._(t`Delete Project`)}
|
name={name}
|
||||||
onConfirm={deleteProject}
|
modalTitle={i18n._(t`Delete Project`)}
|
||||||
isDisabled={isLoading}
|
onConfirm={deleteProject}
|
||||||
>
|
isDisabled={isLoading}
|
||||||
{i18n._(t`Delete`)}
|
>
|
||||||
</DeleteButton>
|
{i18n._(t`Delete`)}
|
||||||
)}
|
</DeleteButton>
|
||||||
|
)}
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
{/* Update delete modal to show dependencies https://github.com/ansible/awx/issues/5546 */}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import { ProjectsAPI } from '../../../api';
|
|||||||
import ProjectDetail from './ProjectDetail';
|
import ProjectDetail from './ProjectDetail';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api');
|
||||||
|
jest.mock('react-router-dom', () => ({
|
||||||
|
...jest.requireActual('react-router-dom'),
|
||||||
|
useRouteMatch: () => ({
|
||||||
|
url: '/projects/1/details',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
describe('<ProjectDetail />', () => {
|
describe('<ProjectDetail />', () => {
|
||||||
const mockProject = {
|
const mockProject = {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -139,13 +144,19 @@ describe('<ProjectDetail />', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show edit button for users with edit permission', async () => {
|
test('should show edit and sync button for users with edit permission', async () => {
|
||||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
const editButton = await waitForElement(
|
const editButton = await waitForElement(
|
||||||
wrapper,
|
wrapper,
|
||||||
'ProjectDetail Button[aria-label="edit"]'
|
'ProjectDetail Button[aria-label="edit"]'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const syncButton = await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'ProjectDetail Button[aria-label="Sync Project"]'
|
||||||
|
);
|
||||||
expect(editButton.text()).toEqual('Edit');
|
expect(editButton.text()).toEqual('Edit');
|
||||||
|
expect(syncButton.text()).toEqual('Sync');
|
||||||
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
expect(editButton.prop('to')).toBe(`/projects/${mockProject.id}/edit`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,6 +177,9 @@ describe('<ProjectDetail />', () => {
|
|||||||
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
expect(wrapper.find('ProjectDetail Button[aria-label="edit"]').length).toBe(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
expect(wrapper.find('ProjectDetail Button[aria-label="sync"]').length).toBe(
|
||||||
|
0
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button should navigate to project edit', () => {
|
test('edit button should navigate to project edit', () => {
|
||||||
@@ -180,6 +194,17 @@ describe('<ProjectDetail />', () => {
|
|||||||
expect(history.location.pathname).toEqual('/projects/1/edit');
|
expect(history.location.pathname).toEqual('/projects/1/edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sync button should call api to syn project', async () => {
|
||||||
|
ProjectsAPI.readSync.mockResolvedValue({ data: { can_update: true } });
|
||||||
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
|
await act(() =>
|
||||||
|
wrapper
|
||||||
|
.find('ProjectDetail Button[aria-label="Sync Project"]')
|
||||||
|
.prop('onClick')(1)
|
||||||
|
);
|
||||||
|
expect(ProjectsAPI.sync).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('expected api calls are made for delete', async () => {
|
test('expected api calls are made for delete', async () => {
|
||||||
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
const wrapper = mountWithContexts(<ProjectDetail project={mockProject} />);
|
||||||
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
|
await waitForElement(wrapper, 'ProjectDetail Button[aria-label="Delete"]');
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('<ProjectEdit />', () => {
|
|||||||
scm_update_on_launch: true,
|
scm_update_on_launch: true,
|
||||||
scm_update_cache_timeout: 3,
|
scm_update_cache_timeout: 3,
|
||||||
allow_override: false,
|
allow_override: false,
|
||||||
custom_virtualenv: '/venv/custom-env',
|
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
credential: {
|
credential: {
|
||||||
id: 100,
|
id: 100,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PencilAltIcon, SyncIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { formatDateString, timeOfDay } from '../../../util/dates';
|
import { formatDateString, timeOfDay } from '../../../util/dates';
|
||||||
import { ProjectsAPI } from '../../../api';
|
import { ProjectsAPI } from '../../../api';
|
||||||
@@ -153,23 +153,10 @@ function ProjectListItem({
|
|||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
{project.summary_fields.user_capabilities.start ? (
|
{project.summary_fields.user_capabilities.start && (
|
||||||
<Tooltip content={i18n._(t`Sync Project`)} position="top">
|
<Tooltip content={i18n._(t`Sync Project`)} position="top">
|
||||||
<ProjectSyncButton projectId={project.id}>
|
<ProjectSyncButton projectId={project.id} />
|
||||||
{handleSync => (
|
|
||||||
<Button
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={i18n._(t`Sync Project`)}
|
|
||||||
variant="plain"
|
|
||||||
onClick={handleSync}
|
|
||||||
>
|
|
||||||
<SyncIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</ProjectSyncButton>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
)}
|
||||||
{project.summary_fields.user_capabilities.edit ? (
|
{project.summary_fields.user_capabilities.edit ? (
|
||||||
<Tooltip content={i18n._(t`Edit Project`)} position="top">
|
<Tooltip content={i18n._(t`Edit Project`)} position="top">
|
||||||
|
|||||||
@@ -284,11 +284,11 @@ function ProjectFormFields({
|
|||||||
data={[
|
data={[
|
||||||
{
|
{
|
||||||
label: i18n._(t`Use Default Ansible Environment`),
|
label: i18n._(t`Use Default Ansible Environment`),
|
||||||
value: '/venv/ansible/',
|
value: '/var/lib/awx/venv/ansible/',
|
||||||
key: 'default',
|
key: 'default',
|
||||||
},
|
},
|
||||||
...custom_virtualenvs
|
...custom_virtualenvs
|
||||||
.filter(datum => datum !== '/venv/ansible/')
|
.filter(datum => datum !== '/var/lib/awx/venv/ansible/')
|
||||||
.map(datum => ({
|
.map(datum => ({
|
||||||
label: datum,
|
label: datum,
|
||||||
value: datum,
|
value: datum,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('<ProjectForm />', () => {
|
|||||||
scm_update_on_launch: true,
|
scm_update_on_launch: true,
|
||||||
scm_update_cache_timeout: 3,
|
scm_update_cache_timeout: 3,
|
||||||
allow_override: false,
|
allow_override: false,
|
||||||
custom_virtualenv: '/venv/custom-env',
|
custom_virtualenv: '/var/lib/awx/venv/custom-env',
|
||||||
summary_fields: {
|
summary_fields: {
|
||||||
credential: {
|
credential: {
|
||||||
id: 100,
|
id: 100,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { useRouteMatch } from 'react-router-dom';
|
||||||
|
import { Button } from '@patternfly/react-core';
|
||||||
|
import { SyncIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
import { number } from 'prop-types';
|
import { number } from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -8,28 +12,27 @@ import AlertModal from '../../../components/AlertModal';
|
|||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import { ProjectsAPI } from '../../../api';
|
import { ProjectsAPI } from '../../../api';
|
||||||
|
|
||||||
function ProjectSyncButton({ i18n, children, projectId }) {
|
function ProjectSyncButton({ i18n, projectId }) {
|
||||||
|
const match = useRouteMatch();
|
||||||
|
|
||||||
const { request: handleSync, error: syncError } = useRequest(
|
const { request: handleSync, error: syncError } = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const { data } = await ProjectsAPI.readSync(projectId);
|
await ProjectsAPI.sync(projectId);
|
||||||
if (data.can_update) {
|
}, [projectId]),
|
||||||
await ProjectsAPI.sync(projectId);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
i18n._(
|
|
||||||
t`You don't have the necessary permissions to sync this project.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [i18n, projectId]),
|
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(syncError);
|
const { error, dismissError } = useDismissableError(syncError);
|
||||||
|
const isDetailsView = match.url.endsWith('/details');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children(handleSync)}
|
<Button
|
||||||
|
aria-label={i18n._(t`Sync Project`)}
|
||||||
|
variant={isDetailsView ? 'secondary' : 'plain'}
|
||||||
|
onClick={handleSync}
|
||||||
|
>
|
||||||
|
{match.url.endsWith('/details') ? i18n._(t`Sync`) : <SyncIcon />}
|
||||||
|
</Button>
|
||||||
{error && (
|
{error && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={error}
|
isOpen={error}
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ jest.mock('../../../api');
|
|||||||
|
|
||||||
describe('ProjectSyncButton', () => {
|
describe('ProjectSyncButton', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
ProjectsAPI.readSync.mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
can_update: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const children = handleSync => (
|
const children = handleSync => (
|
||||||
<button type="submit" onClick={() => handleSync()} />
|
<button type="submit" onClick={() => handleSync()} />
|
||||||
@@ -43,8 +38,7 @@ describe('ProjectSyncButton', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
button.prop('onClick')();
|
button.prop('onClick')();
|
||||||
});
|
});
|
||||||
expect(ProjectsAPI.readSync).toHaveBeenCalledWith(1);
|
|
||||||
await sleep(0);
|
|
||||||
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
|
expect(ProjectsAPI.sync).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
test('displays error modal after unsuccessful sync', async () => {
|
test('displays error modal after unsuccessful sync', async () => {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function ActivityStreamDetail({ i18n }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Edit`)}
|
aria-label={i18n._(t`Edit`)}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
ouiaId="edit-button"
|
||||||
to="/settings/activity_stream/edit"
|
to="/settings/activity_stream/edit"
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function AzureADDetail({ i18n }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Edit`)}
|
aria-label={i18n._(t`Edit`)}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
ouiaId="edit-button"
|
||||||
to="/settings/azure/edit"
|
to="/settings/azure/edit"
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { PageSection, Card } from '@patternfly/react-core';
|
|||||||
import ContentError from '../../../components/ContentError';
|
import ContentError from '../../../components/ContentError';
|
||||||
import GitHubDetail from './GitHubDetail';
|
import GitHubDetail from './GitHubDetail';
|
||||||
import GitHubEdit from './GitHubEdit';
|
import GitHubEdit from './GitHubEdit';
|
||||||
|
import GitHubOrgEdit from './GitHubOrgEdit';
|
||||||
|
import GitHubTeamEdit from './GitHubTeamEdit';
|
||||||
|
|
||||||
function GitHub({ i18n }) {
|
function GitHub({ i18n }) {
|
||||||
const baseURL = '/settings/github';
|
const baseURL = '/settings/github';
|
||||||
@@ -29,9 +31,15 @@ function GitHub({ i18n }) {
|
|||||||
<Route path={`${baseURL}/:category/details`}>
|
<Route path={`${baseURL}/:category/details`}>
|
||||||
<GitHubDetail />
|
<GitHubDetail />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={`${baseURL}/:category/edit`}>
|
<Route path={`${baseURL}/default/edit`}>
|
||||||
<GitHubEdit />
|
<GitHubEdit />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path={`${baseURL}/organization/edit`}>
|
||||||
|
<GitHubOrgEdit />
|
||||||
|
</Route>
|
||||||
|
<Route path={`${baseURL}/team/edit`}>
|
||||||
|
<GitHubTeamEdit />
|
||||||
|
</Route>
|
||||||
<Route key="not-found" path={`${baseURL}/*`}>
|
<Route key="not-found" path={`${baseURL}/*`}>
|
||||||
<ContentError isNotFound>
|
<ContentError isNotFound>
|
||||||
<Link to={`${baseURL}/default/details`}>
|
<Link to={`${baseURL}/default/details`}>
|
||||||
|
|||||||
@@ -5,33 +5,94 @@ import {
|
|||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
import GitHub from './GitHub';
|
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
|
import GitHub from './GitHub';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<GitHub />', () => {
|
describe('<GitHub />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/github/',
|
||||||
|
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
|
||||||
|
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/github-org/',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/github-team/',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render github details', async () => {
|
test('should render github default details', async () => {
|
||||||
const history = createMemoryHistory({
|
const history = createMemoryHistory({
|
||||||
initialEntries: ['/settings/github/'],
|
initialEntries: ['/settings/github/'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<GitHub />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<GitHub />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||||
|
expect(wrapper.find('Detail[label="GitHub OAuth2 Key"]').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should redirect to github organization category details', async () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/github/organization'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHub />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('GitHubDetail').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Detail[label="GitHub Organization OAuth2 Key"]').length
|
||||||
|
).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render github edit', async () => {
|
test('should render github edit', async () => {
|
||||||
@@ -39,9 +100,14 @@ describe('<GitHub />', () => {
|
|||||||
initialEntries: ['/settings/github/default/edit'],
|
initialEntries: ['/settings/github/default/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<GitHub />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<GitHub />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
||||||
@@ -52,9 +118,14 @@ describe('<GitHub />', () => {
|
|||||||
initialEntries: ['/settings/github/foo/bar'],
|
initialEntries: ['/settings/github/foo/bar'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<GitHub />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<GitHub />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('ContentError').length).toBe(1);
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ function GitHubDetail({ i18n }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Edit`)}
|
aria-label={i18n._(t`Edit`)}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
ouiaId="edit-button"
|
||||||
to={`${baseURL}/${category}/edit`}
|
to={`${baseURL}/${category}/edit`}
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
|
|||||||
@@ -1,25 +1,141 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import {
|
||||||
|
EncryptedField,
|
||||||
|
InputField,
|
||||||
|
ObjectField,
|
||||||
|
} from '../../shared/SharedFields';
|
||||||
|
import { formatJson } from '../../shared/settingUtils';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function GitHubEdit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('github');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
if (!options[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGithub();
|
||||||
|
}, [fetchGithub]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/github/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm({
|
||||||
|
...form,
|
||||||
|
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_MAP: formatJson(form.SOCIAL_AUTH_GITHUB_TEAM_MAP),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(github).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/github/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||||
|
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||||
|
acc[key] = fields[key].value
|
||||||
|
? JSON.stringify(fields[key].value, null, 2)
|
||||||
|
: emptyDefault;
|
||||||
|
} else {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
function GitHubEdit({ i18n }) {
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && github && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
|
||||||
component={Link}
|
{formik => (
|
||||||
to="/settings/github/details"
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
>
|
<FormColumnLayout>
|
||||||
{i18n._(t`Cancel`)}
|
<InputField
|
||||||
</Button>
|
name="SOCIAL_AUTH_GITHUB_KEY"
|
||||||
</CardActionsRow>
|
config={github.SOCIAL_AUTH_GITHUB_KEY}
|
||||||
|
/>
|
||||||
|
<EncryptedField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_SECRET"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_SECRET}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_MAP}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(GitHubEdit);
|
export default GitHubEdit;
|
||||||
|
|||||||
@@ -1,16 +1,173 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||||
|
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
import GitHubEdit from './GitHubEdit';
|
import GitHubEdit from './GitHubEdit';
|
||||||
|
|
||||||
|
jest.mock('../../../../api/models/Settings');
|
||||||
|
SettingsAPI.updateAll.mockResolvedValue({});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_CALLBACK_URL: 'https://foo/complete/github/',
|
||||||
|
SOCIAL_AUTH_GITHUB_KEY: 'mock github key',
|
||||||
|
SOCIAL_AUTH_GITHUB_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
|
||||||
|
Default: {
|
||||||
|
users: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('<GitHubEdit />', () => {
|
describe('<GitHubEdit />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
let history;
|
||||||
wrapper = mountWithContexts(<GitHubEdit />);
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/github/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubEdit />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('initially renders without crashing', () => {
|
test('initially renders without crashing', () => {
|
||||||
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
expect(wrapper.find('GitHubEdit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should display expected form fields', async () => {
|
||||||
|
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Key"]').length).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="GitHub OAuth2 Secret"]').length).toBe(
|
||||||
|
1
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub OAuth2 Organization Map"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub OAuth2 Team Map"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send default values to api on form revert all', async () => {
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Revert all to default"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_MAP: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send request to api on form submission', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper
|
||||||
|
.find(
|
||||||
|
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_SECRET"] button[aria-label="Revert"]'
|
||||||
|
)
|
||||||
|
.invoke('onClick')();
|
||||||
|
wrapper.find('input#SOCIAL_AUTH_GITHUB_KEY').simulate('change', {
|
||||||
|
target: { value: 'new key', name: 'SOCIAL_AUTH_GITHUB_KEY' },
|
||||||
|
});
|
||||||
|
wrapper
|
||||||
|
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP')
|
||||||
|
.invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_KEY: 'new key',
|
||||||
|
SOCIAL_AUTH_GITHUB_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_ORGANIZATION_MAP: {
|
||||||
|
Default: {
|
||||||
|
users: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github default detail on successful submission', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/github/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github default detail when cancel is clicked', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/github/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display error message on unsuccessful submission', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display ContentError on throw', async () => {
|
||||||
|
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubEdit />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { Form } from '@patternfly/react-core';
|
||||||
|
import { CardBody } from '../../../../components/Card';
|
||||||
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import {
|
||||||
|
EncryptedField,
|
||||||
|
InputField,
|
||||||
|
ObjectField,
|
||||||
|
} from '../../shared/SharedFields';
|
||||||
|
import { formatJson } from '../../shared/settingUtils';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function GitHubOrgEdit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('github-org');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
if (!options[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGithub();
|
||||||
|
}, [fetchGithub]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/github/organization/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm({
|
||||||
|
...form,
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(github).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/github/organization/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||||
|
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||||
|
acc[key] = fields[key].value
|
||||||
|
? JSON.stringify(fields[key].value, null, 2)
|
||||||
|
: emptyDefault;
|
||||||
|
} else {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
{isLoading && <ContentLoading />}
|
||||||
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
|
{!isLoading && github && (
|
||||||
|
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<InputField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORG_KEY"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORG_KEY}
|
||||||
|
/>
|
||||||
|
<EncryptedField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORG_SECRET"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORG_SECRET}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORG_NAME"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORG_NAME}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GitHubOrgEdit;
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||||
|
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
import GitHubOrgEdit from './GitHubOrgEdit';
|
||||||
|
|
||||||
|
jest.mock('../../../../api/models/Settings');
|
||||||
|
SettingsAPI.updateAll.mockResolvedValue({});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/github-org/',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<GitHubOrgEdit />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/github/organization/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubOrgEdit />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders without crashing', () => {
|
||||||
|
expect(wrapper.find('GitHubOrgEdit').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display expected form fields', async () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Key"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Secret"]')
|
||||||
|
.length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Organization Name"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find(
|
||||||
|
'FormGroup[label="GitHub Organization OAuth2 Organization Map"]'
|
||||||
|
).length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Organization OAuth2 Team Map"]')
|
||||||
|
.length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send default values to api on form revert all', async () => {
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Revert all to default"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_NAME: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send request to api on form submission', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper
|
||||||
|
.find(
|
||||||
|
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_ORG_SECRET"] button[aria-label="Revert"]'
|
||||||
|
)
|
||||||
|
.invoke('onClick')();
|
||||||
|
wrapper.find('input#SOCIAL_AUTH_GITHUB_ORG_NAME').simulate('change', {
|
||||||
|
target: { value: 'new org', name: 'SOCIAL_AUTH_GITHUB_ORG_NAME' },
|
||||||
|
});
|
||||||
|
wrapper
|
||||||
|
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP')
|
||||||
|
.invoke('onChange')('{\n"Default":{\n"users":\nfalse\n}\n}');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_NAME: 'new org',
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_TEAM_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_ORG_ORGANIZATION_MAP: {
|
||||||
|
Default: {
|
||||||
|
users: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github organization detail on successful submission', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/settings/github/organization/details'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github organization detail when cancel is clicked', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/settings/github/organization/details'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display error message on unsuccessful submission', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display ContentError on throw', async () => {
|
||||||
|
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubOrgEdit />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './GitHubOrgEdit';
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
import { Formik } from 'formik';
|
||||||
|
import { Form } from '@patternfly/react-core';
|
||||||
|
import { CardBody } from '../../../../components/Card';
|
||||||
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import {
|
||||||
|
EncryptedField,
|
||||||
|
InputField,
|
||||||
|
ObjectField,
|
||||||
|
} from '../../shared/SharedFields';
|
||||||
|
import { formatJson } from '../../shared/settingUtils';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function GitHubTeamEdit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const { isLoading, error, request: fetchGithub, result: github } = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('github-team');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
if (!options[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGithub();
|
||||||
|
}, [fetchGithub]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/github/team/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm({
|
||||||
|
...form,
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(github).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/github/team/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||||
|
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||||
|
acc[key] = fields[key].value
|
||||||
|
? JSON.stringify(fields[key].value, null, 2)
|
||||||
|
: emptyDefault;
|
||||||
|
} else {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
{isLoading && <ContentLoading />}
|
||||||
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
|
{!isLoading && github && (
|
||||||
|
<Formik initialValues={initialValues(github)} onSubmit={handleSubmit}>
|
||||||
|
{formik => (
|
||||||
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
|
<FormColumnLayout>
|
||||||
|
<InputField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_KEY"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_KEY}
|
||||||
|
/>
|
||||||
|
<EncryptedField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_SECRET"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_SECRET}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_ID"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_ID}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP"
|
||||||
|
config={github.SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GitHubTeamEdit;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../../../testUtils/enzymeHelpers';
|
||||||
|
import mockAllOptions from '../../shared/data.allSettingOptions.json';
|
||||||
|
import { SettingsProvider } from '../../../../contexts/Settings';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
import GitHubTeamEdit from './GitHubTeamEdit';
|
||||||
|
|
||||||
|
jest.mock('../../../../api/models/Settings');
|
||||||
|
SettingsAPI.updateAll.mockResolvedValue({});
|
||||||
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/github-team/',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ID: 'team_id',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<GitHubTeamEdit />', () => {
|
||||||
|
let wrapper;
|
||||||
|
let history;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
history = createMemoryHistory({
|
||||||
|
initialEntries: ['/settings/github/team/edit'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubTeamEdit />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initially renders without crashing', () => {
|
||||||
|
expect(wrapper.find('GitHubTeamEdit').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display expected form fields', async () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Team OAuth2 Key"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Team OAuth2 Secret"]').length
|
||||||
|
).toBe(1);
|
||||||
|
expect(wrapper.find('FormGroup[label="GitHub Team ID"]').length).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Team OAuth2 Organization Map"]')
|
||||||
|
.length
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('FormGroup[label="GitHub Team OAuth2 Team Map"]').length
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send default values to api on form revert all', async () => {
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('button[aria-label="Revert all to default"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('RevertAllAlert')).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper
|
||||||
|
.find('RevertAllAlert button[aria-label="Confirm revert all"]')
|
||||||
|
.invoke('onClick')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_KEY: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ID: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: null,
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should successfully send request to api on form submission', async () => {
|
||||||
|
act(() => {
|
||||||
|
wrapper
|
||||||
|
.find(
|
||||||
|
'FormGroup[fieldId="SOCIAL_AUTH_GITHUB_TEAM_SECRET"] button[aria-label="Revert"]'
|
||||||
|
)
|
||||||
|
.invoke('onClick')();
|
||||||
|
wrapper.find('input#SOCIAL_AUTH_GITHUB_TEAM_ID').simulate('change', {
|
||||||
|
target: { value: '12345', name: 'SOCIAL_AUTH_GITHUB_TEAM_ID' },
|
||||||
|
});
|
||||||
|
wrapper
|
||||||
|
.find('CodeMirrorInput#SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP')
|
||||||
|
.invoke('onChange')('{\n"Default":{\n"users":\ntrue\n}\n}');
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledWith({
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_KEY: 'OAuth2 key (Client ID)',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_SECRET: '',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ID: '12345',
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_TEAM_MAP: {},
|
||||||
|
SOCIAL_AUTH_GITHUB_TEAM_ORGANIZATION_MAP: {
|
||||||
|
Default: {
|
||||||
|
users: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github team detail on successful submission', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/github/team/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to github team detail when cancel is clicked', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||||
|
});
|
||||||
|
expect(history.location.pathname).toEqual('/settings/github/team/details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display error message on unsuccessful submission', async () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: { detail: 'An error occurred' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
SettingsAPI.updateAll.mockImplementation(() => Promise.reject(error));
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('Form').invoke('onSubmit')();
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||||
|
expect(SettingsAPI.updateAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display ContentError on throw', async () => {
|
||||||
|
SettingsAPI.readCategory.mockImplementationOnce(() =>
|
||||||
|
Promise.reject(new Error())
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
|
<GitHubTeamEdit />
|
||||||
|
</SettingsProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './GitHubTeamEdit';
|
||||||
@@ -2,13 +2,28 @@ import React from 'react';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
import GoogleOAuth2 from './GoogleOAuth2';
|
import { SettingsProvider } from '../../../contexts/Settings';
|
||||||
|
|
||||||
import { SettingsAPI } from '../../../api';
|
import { SettingsAPI } from '../../../api';
|
||||||
|
import mockAllOptions from '../shared/data.allSettingOptions.json';
|
||||||
|
import GoogleOAuth2 from './GoogleOAuth2';
|
||||||
|
|
||||||
jest.mock('../../../api/models/Settings');
|
jest.mock('../../../api/models/Settings');
|
||||||
SettingsAPI.readCategory.mockResolvedValue({
|
SettingsAPI.readCategory.mockResolvedValue({
|
||||||
data: {},
|
data: {
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_CALLBACK_URL:
|
||||||
|
'https://towerhost/sso/complete/google-oauth2/',
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY: 'mock key',
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET: '$encrypted$',
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: [
|
||||||
|
'example.com',
|
||||||
|
'example_2.com',
|
||||||
|
],
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: {},
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: {
|
||||||
|
Default: {},
|
||||||
|
},
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<GoogleOAuth2 />', () => {
|
describe('<GoogleOAuth2 />', () => {
|
||||||
@@ -24,9 +39,14 @@ describe('<GoogleOAuth2 />', () => {
|
|||||||
initialEntries: ['/settings/google_oauth2/details'],
|
initialEntries: ['/settings/google_oauth2/details'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<GoogleOAuth2 />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
|
expect(wrapper.find('GoogleOAuth2Detail').length).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -36,9 +56,14 @@ describe('<GoogleOAuth2 />', () => {
|
|||||||
initialEntries: ['/settings/google_oauth2/edit'],
|
initialEntries: ['/settings/google_oauth2/edit'],
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<GoogleOAuth2 />, {
|
wrapper = mountWithContexts(
|
||||||
context: { router: { history } },
|
<SettingsProvider value={mockAllOptions.actions}>
|
||||||
});
|
<GoogleOAuth2 />
|
||||||
|
</SettingsProvider>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
|
expect(wrapper.find('GoogleOAuth2Edit').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function GoogleOAuth2Detail({ i18n }) {
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Edit`)}
|
aria-label={i18n._(t`Edit`)}
|
||||||
component={Link}
|
component={Link}
|
||||||
|
ouiaId="edit-button"
|
||||||
to="/settings/google_oauth2/edit"
|
to="/settings/google_oauth2/edit"
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
|
|||||||
@@ -1,25 +1,171 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { Formik } from 'formik';
|
||||||
import { t } from '@lingui/macro';
|
import { Form } from '@patternfly/react-core';
|
||||||
import { Button } from '@patternfly/react-core';
|
import { CardBody } from '../../../../components/Card';
|
||||||
import { CardBody, CardActionsRow } from '../../../../components/Card';
|
import ContentError from '../../../../components/ContentError';
|
||||||
|
import ContentLoading from '../../../../components/ContentLoading';
|
||||||
|
import { FormSubmitError } from '../../../../components/FormField';
|
||||||
|
import { FormColumnLayout } from '../../../../components/FormLayout';
|
||||||
|
import { useSettings } from '../../../../contexts/Settings';
|
||||||
|
import { RevertAllAlert, RevertFormActionGroup } from '../../shared';
|
||||||
|
import {
|
||||||
|
EncryptedField,
|
||||||
|
InputField,
|
||||||
|
ObjectField,
|
||||||
|
} from '../../shared/SharedFields';
|
||||||
|
import { formatJson } from '../../shared/settingUtils';
|
||||||
|
import useModal from '../../../../util/useModal';
|
||||||
|
import useRequest from '../../../../util/useRequest';
|
||||||
|
import { SettingsAPI } from '../../../../api';
|
||||||
|
|
||||||
|
function GoogleOAuth2Edit() {
|
||||||
|
const history = useHistory();
|
||||||
|
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||||
|
const { PUT: options } = useSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
request: fetchGoogleOAuth2,
|
||||||
|
result: googleOAuth2,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const { data } = await SettingsAPI.readCategory('google-oauth2');
|
||||||
|
const mergedData = {};
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
if (!options[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mergedData[key] = options[key];
|
||||||
|
mergedData[key].value = data[key];
|
||||||
|
});
|
||||||
|
return mergedData;
|
||||||
|
}, [options]),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGoogleOAuth2();
|
||||||
|
}, [fetchGoogleOAuth2]);
|
||||||
|
|
||||||
|
const { error: submitError, request: submitForm } = useRequest(
|
||||||
|
useCallback(
|
||||||
|
async values => {
|
||||||
|
await SettingsAPI.updateAll(values);
|
||||||
|
history.push('/settings/google_oauth2/details');
|
||||||
|
},
|
||||||
|
[history]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async form => {
|
||||||
|
await submitForm({
|
||||||
|
...form,
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
|
||||||
|
),
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP: formatJson(
|
||||||
|
form.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertAll = async () => {
|
||||||
|
const defaultValues = Object.assign(
|
||||||
|
...Object.entries(googleOAuth2).map(([key, value]) => ({
|
||||||
|
[key]: value.default,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
await submitForm(defaultValues);
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
history.push('/settings/google_oauth2/details');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialValues = fields =>
|
||||||
|
Object.keys(fields).reduce((acc, key) => {
|
||||||
|
if (fields[key].type === 'list' || fields[key].type === 'nested object') {
|
||||||
|
const emptyDefault = fields[key].type === 'list' ? '[]' : '{}';
|
||||||
|
acc[key] = fields[key].value
|
||||||
|
? JSON.stringify(fields[key].value, null, 2)
|
||||||
|
: emptyDefault;
|
||||||
|
} else {
|
||||||
|
acc[key] = fields[key].value ?? '';
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
function GoogleOAuth2Edit({ i18n }) {
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{i18n._(t`Edit form coming soon :)`)}
|
{isLoading && <ContentLoading />}
|
||||||
<CardActionsRow>
|
{!isLoading && error && <ContentError error={error} />}
|
||||||
<Button
|
{!isLoading && googleOAuth2 && (
|
||||||
aria-label={i18n._(t`Cancel`)}
|
<Formik
|
||||||
component={Link}
|
initialValues={initialValues(googleOAuth2)}
|
||||||
to="/settings/google_oauth2/details"
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel`)}
|
{formik => (
|
||||||
</Button>
|
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||||
</CardActionsRow>
|
<FormColumnLayout>
|
||||||
|
<InputField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_KEY"
|
||||||
|
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY}
|
||||||
|
/>
|
||||||
|
<EncryptedField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET"
|
||||||
|
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS"
|
||||||
|
config={
|
||||||
|
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_WHITELISTED_DOMAINS
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS"
|
||||||
|
config={
|
||||||
|
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP"
|
||||||
|
config={
|
||||||
|
googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_ORGANIZATION_MAP
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ObjectField
|
||||||
|
name="SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP"
|
||||||
|
config={googleOAuth2.SOCIAL_AUTH_GOOGLE_OAUTH2_TEAM_MAP}
|
||||||
|
/>
|
||||||
|
{submitError && <FormSubmitError error={submitError} />}
|
||||||
|
</FormColumnLayout>
|
||||||
|
<RevertFormActionGroup
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onSubmit={formik.handleSubmit}
|
||||||
|
onRevert={toggleModal}
|
||||||
|
/>
|
||||||
|
{isModalOpen && (
|
||||||
|
<RevertAllAlert
|
||||||
|
onClose={closeModal}
|
||||||
|
onRevertAll={handleRevertAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withI18n()(GoogleOAuth2Edit);
|
export default GoogleOAuth2Edit;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user