mirror of
https://github.com/ansible/awx.git
synced 2026-03-07 19:51:08 -03:30
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
|
||||||
|
|
||||||
|
# 19.0.0 (TBD)
|
||||||
|
|
||||||
|
* AWX now runs on Python 3.8 (https://github.com/ansible/awx/pull/8778/)
|
||||||
|
* Added support for Execution Environments to the Activity Stream (https://github.com/ansible/awx/issues/9308)
|
||||||
|
* Fixed a bug that improperly formats OpenSSH keys specified in custom Credential Types (https://github.com/ansible/awx/issues/9361)
|
||||||
|
|
||||||
# 18.0.0 (March 23, 2021)
|
# 18.0.0 (March 23, 2021)
|
||||||
|
|
||||||
**IMPORTANT INSTALL AND UPGRADE NOTES**
|
**IMPORTANT INSTALL AND UPGRADE NOTES**
|
||||||
|
|||||||
18
Makefile
18
Makefile
@@ -1,4 +1,4 @@
|
|||||||
PYTHON ?= python3
|
PYTHON ?= python3.8
|
||||||
PYTHON_VERSION = $(shell $(PYTHON) -c "from distutils.sysconfig import get_python_version; print(get_python_version())")
|
PYTHON_VERSION = $(shell $(PYTHON) -c "from distutils.sysconfig import get_python_version; print(get_python_version())")
|
||||||
SITELIB=$(shell $(PYTHON) -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")
|
SITELIB=$(shell $(PYTHON) -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")
|
||||||
OFFICIAL ?= no
|
OFFICIAL ?= no
|
||||||
@@ -23,7 +23,7 @@ VENV_BASE ?= /var/lib/awx/venv/
|
|||||||
SCL_PREFIX ?=
|
SCL_PREFIX ?=
|
||||||
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db
|
||||||
|
|
||||||
DEV_DOCKER_TAG_BASE ?= gcr.io/ansible-tower-engineering
|
DEV_DOCKER_TAG_BASE ?= quay.io/awx
|
||||||
DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
DEVEL_IMAGE_NAME ?= $(DEV_DOCKER_TAG_BASE)/awx_devel:$(COMPOSE_TAG)
|
||||||
|
|
||||||
# Python packages to install only from source (not from binary wheels)
|
# Python packages to install only from source (not from binary wheels)
|
||||||
@@ -125,7 +125,7 @@ virtualenv_awx:
|
|||||||
mkdir $(VENV_BASE); \
|
mkdir $(VENV_BASE); \
|
||||||
fi; \
|
fi; \
|
||||||
if [ ! -d "$(VENV_BASE)/awx" ]; then \
|
if [ ! -d "$(VENV_BASE)/awx" ]; then \
|
||||||
virtualenv -p $(PYTHON) $(VENV_BASE)/awx; \
|
$(PYTHON) -m venv $(VENV_BASE)/awx; \
|
||||||
$(VENV_BASE)/awx/bin/pip install $(PIP_OPTIONS) $(VENV_BOOTSTRAP); \
|
$(VENV_BASE)/awx/bin/pip install $(PIP_OPTIONS) $(VENV_BOOTSTRAP); \
|
||||||
fi; \
|
fi; \
|
||||||
fi
|
fi
|
||||||
@@ -164,7 +164,7 @@ version_file:
|
|||||||
if [ "$(VENV_BASE)" ]; then \
|
if [ "$(VENV_BASE)" ]; then \
|
||||||
. $(VENV_BASE)/awx/bin/activate; \
|
. $(VENV_BASE)/awx/bin/activate; \
|
||||||
fi; \
|
fi; \
|
||||||
python -c "import awx; print(awx.__version__)" > /var/lib/awx/.awx_version; \
|
$(PYTHON) -c "import awx; print(awx.__version__)" > /var/lib/awx/.awx_version; \
|
||||||
|
|
||||||
# Do any one-time init tasks.
|
# Do any one-time init tasks.
|
||||||
comma := ,
|
comma := ,
|
||||||
@@ -272,12 +272,12 @@ reports:
|
|||||||
mkdir -p $@
|
mkdir -p $@
|
||||||
|
|
||||||
black: reports
|
black: reports
|
||||||
command -v black >/dev/null 2>&1 || { echo "could not find black on your PATH, you may need to \`pip install black\`, or set AWX_IGNORE_BLACK=1" && exit 1; }
|
@command -v black >/dev/null 2>&1 || { echo "could not find black on your PATH, you may need to \`pip install black\`, or set AWX_IGNORE_BLACK=1" && exit 1; }
|
||||||
(set -o pipefail && $@ $(BLACK_ARGS) awx awxkit awx_collection | tee reports/$@.report)
|
@(set -o pipefail && $@ $(BLACK_ARGS) awx awxkit awx_collection | tee reports/$@.report)
|
||||||
|
|
||||||
.git/hooks/pre-commit:
|
.git/hooks/pre-commit:
|
||||||
echo "[ -z \$$AWX_IGNORE_BLACK ] && (black --check \`git diff --cached --name-only | grep -E '\.py$\'\` || (echo 'To fix this, run \`make black\` to auto-format your code prior to commit, or set AWX_IGNORE_BLACK=1' && exit 1))" > .git/hooks/pre-commit
|
@echo "[ -z \$$AWX_IGNORE_BLACK ] && (black --check \`git diff --cached --name-only --diff-filter=AM | grep -E '\.py$\'\` || (echo 'To fix this, run \`make black\` to auto-format your code prior to commit, or set AWX_IGNORE_BLACK=1' && exit 1))" > .git/hooks/pre-commit
|
||||||
chmod +x .git/hooks/pre-commit
|
@chmod +x .git/hooks/pre-commit
|
||||||
|
|
||||||
genschema: reports
|
genschema: reports
|
||||||
$(MAKE) swagger PYTEST_ARGS="--genschema --create-db "
|
$(MAKE) swagger PYTEST_ARGS="--genschema --create-db "
|
||||||
@@ -292,7 +292,7 @@ swagger: reports
|
|||||||
check: black
|
check: black
|
||||||
|
|
||||||
awx-link:
|
awx-link:
|
||||||
[ -d "/awx_devel/awx.egg-info" ] || python3 /awx_devel/setup.py egg_info_dev
|
[ -d "/awx_devel/awx.egg-info" ] || $(PYTHON) /awx_devel/setup.py egg_info_dev
|
||||||
cp -f /tmp/awx.egg-link /var/lib/awx/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
|
||||||
|
|||||||
@@ -129,6 +129,18 @@ class PrometheusJSONRenderer(renderers.JSONRenderer):
|
|||||||
parsed_metrics = text_string_to_metric_families(data)
|
parsed_metrics = text_string_to_metric_families(data)
|
||||||
data = {}
|
data = {}
|
||||||
for family in parsed_metrics:
|
for family in parsed_metrics:
|
||||||
|
data[family.name] = {}
|
||||||
|
data[family.name]['help_text'] = family.documentation
|
||||||
|
data[family.name]['type'] = family.type
|
||||||
|
data[family.name]['samples'] = []
|
||||||
for sample in family.samples:
|
for sample in family.samples:
|
||||||
data[sample[0]] = {"labels": sample[1], "value": sample[2]}
|
sample_dict = {"labels": sample[1], "value": sample[2]}
|
||||||
|
if family.type == 'histogram':
|
||||||
|
if sample[0].endswith("_sum"):
|
||||||
|
sample_dict['sample_type'] = "sum"
|
||||||
|
elif sample[0].endswith("_count"):
|
||||||
|
sample_dict['sample_type'] = "count"
|
||||||
|
elif sample[0].endswith("_bucket"):
|
||||||
|
sample_dict['sample_type'] = "bucket"
|
||||||
|
data[family.name]['samples'].append(sample_dict)
|
||||||
return super(PrometheusJSONRenderer, self).render(data, accepted_media_type, renderer_context)
|
return super(PrometheusJSONRenderer, self).render(data, accepted_media_type, renderer_context)
|
||||||
|
|||||||
1
awx/api/templates/api/metrics_view.md
Normal file
1
awx/api/templates/api/metrics_view.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
query params to filter response, e.g., ?subsystemonly=1&metric=callback_receiver_events_insert_db&node=awx-1
|
||||||
@@ -3043,6 +3043,8 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView):
|
|||||||
return Response(data, status=status.HTTP_201_CREATED)
|
return Response(data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def check_permissions(self, request):
|
def check_permissions(self, request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise PermissionDenied()
|
||||||
obj = self.get_object().workflow_job_template
|
obj = self.get_object().workflow_job_template
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
if not request.user.can_access(models.WorkflowJobTemplate, 'change', obj, request.data):
|
if not request.user.can_access(models.WorkflowJobTemplate, 'change', obj, request.data):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from rest_framework.exceptions import PermissionDenied
|
|||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
# from awx.main.analytics import collectors
|
# from awx.main.analytics import collectors
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
from awx.main.analytics.metrics import metrics
|
from awx.main.analytics.metrics import metrics
|
||||||
from awx.api import renderers
|
from awx.api import renderers
|
||||||
|
|
||||||
@@ -33,5 +34,10 @@ class MetricsView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
''' Show Metrics Details '''
|
''' Show Metrics Details '''
|
||||||
if request.user.is_superuser or request.user.is_system_auditor:
|
if request.user.is_superuser or request.user.is_system_auditor:
|
||||||
return Response(metrics().decode('UTF-8'))
|
metrics_to_show = ''
|
||||||
|
if not request.query_params.get('subsystemonly', "0") == "1":
|
||||||
|
metrics_to_show += metrics().decode('UTF-8')
|
||||||
|
if not request.query_params.get('dbonly', "0") == "1":
|
||||||
|
metrics_to_show += s_metrics.metrics(request)
|
||||||
|
return Response(metrics_to_show)
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from awx.api.generics import APIView
|
|||||||
from awx.conf.registry import settings_registry
|
from awx.conf.registry import settings_registry
|
||||||
from awx.main.analytics import all_collectors
|
from awx.main.analytics import all_collectors
|
||||||
from awx.main.ha import is_ha_environment
|
from awx.main.ha import is_ha_environment
|
||||||
from awx.main.utils import get_awx_version, get_ansible_version, get_custom_venv_choices, to_python_boolean
|
from awx.main.utils import get_awx_version, get_custom_venv_choices, to_python_boolean
|
||||||
from awx.main.utils.licensing import validate_entitlement_manifest
|
from awx.main.utils.licensing import validate_entitlement_manifest
|
||||||
from awx.api.versioning import reverse, drf_reverse
|
from awx.api.versioning import reverse, drf_reverse
|
||||||
from awx.main.constants import PRIVILEGE_ESCALATION_METHODS
|
from awx.main.constants import PRIVILEGE_ESCALATION_METHODS
|
||||||
@@ -279,7 +279,6 @@ class ApiV2ConfigView(APIView):
|
|||||||
time_zone=settings.TIME_ZONE,
|
time_zone=settings.TIME_ZONE,
|
||||||
license_info=license_data,
|
license_info=license_data,
|
||||||
version=get_awx_version(),
|
version=get_awx_version(),
|
||||||
ansible_version=get_ansible_version(),
|
|
||||||
eula=render_to_string("eula.md") if license_data.get('license_type', 'UNLICENSED') != 'open' else '',
|
eula=render_to_string("eula.md") if license_data.get('license_type', 'UNLICENSED') != 'open' else '',
|
||||||
analytics_status=pendo_state,
|
analytics_status=pendo_state,
|
||||||
analytics_collectors=all_collectors(),
|
analytics_collectors=all_collectors(),
|
||||||
|
|||||||
16
awx/conf/migrations/0009_rename_proot_settings.py
Normal file
16
awx/conf/migrations/0009_rename_proot_settings.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
from django.db import migrations
|
||||||
|
from awx.conf.migrations import _rename_setting
|
||||||
|
|
||||||
|
|
||||||
|
def rename_proot_settings(apps, schema_editor):
|
||||||
|
_rename_setting.rename_setting(apps, schema_editor, old_key='AWX_PROOT_BASE_PATH', new_key='AWX_ISOLATION_BASE_PATH')
|
||||||
|
_rename_setting.rename_setting(apps, schema_editor, old_key='AWX_PROOT_SHOW_PATHS', new_key='AWX_ISOLATION_SHOW_PATHS')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [('conf', '0008_subscriptions')]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(rename_proot_settings)]
|
||||||
14
awx/main/analytics/analytics_tasks.py
Normal file
14
awx/main/analytics/analytics_tasks.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# AWX
|
||||||
|
from awx.main.analytics.subsystem_metrics import Metrics
|
||||||
|
from awx.main.dispatch.publish import task
|
||||||
|
from awx.main.dispatch import get_local_queuename
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.main.scheduler')
|
||||||
|
|
||||||
|
|
||||||
|
@task(queue=get_local_queuename)
|
||||||
|
def send_subsystem_metrics():
|
||||||
|
Metrics().send_metrics()
|
||||||
@@ -2,6 +2,7 @@ import io
|
|||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import platform
|
import platform
|
||||||
|
import distro
|
||||||
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
@@ -10,7 +11,7 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils import get_awx_version, get_ansible_version, get_custom_venv_choices, camelcase_to_underscore
|
from awx.main.utils import get_awx_version, get_custom_venv_choices, camelcase_to_underscore
|
||||||
from awx.main import models
|
from awx.main import models
|
||||||
from django.contrib.sessions.models import Session
|
from django.contrib.sessions.models import Session
|
||||||
from awx.main.analytics import register
|
from awx.main.analytics import register
|
||||||
@@ -32,7 +33,7 @@ data _since_ the last report date - i.e., new data in the last 24 hours)
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
@register('config', '1.2', description=_('General platform configuration.'))
|
@register('config', '1.3', description=_('General platform configuration.'))
|
||||||
def config(since, **kwargs):
|
def config(since, **kwargs):
|
||||||
license_info = get_license()
|
license_info = get_license()
|
||||||
install_type = 'traditional'
|
install_type = 'traditional'
|
||||||
@@ -43,7 +44,7 @@ def config(since, **kwargs):
|
|||||||
return {
|
return {
|
||||||
'platform': {
|
'platform': {
|
||||||
'system': platform.system(),
|
'system': platform.system(),
|
||||||
'dist': platform.dist(),
|
'dist': distro.linux_distribution(),
|
||||||
'release': platform.release(),
|
'release': platform.release(),
|
||||||
'type': install_type,
|
'type': install_type,
|
||||||
},
|
},
|
||||||
@@ -51,7 +52,6 @@ def config(since, **kwargs):
|
|||||||
'instance_uuid': settings.SYSTEM_UUID,
|
'instance_uuid': settings.SYSTEM_UUID,
|
||||||
'tower_url_base': settings.TOWER_URL_BASE,
|
'tower_url_base': settings.TOWER_URL_BASE,
|
||||||
'tower_version': get_awx_version(),
|
'tower_version': get_awx_version(),
|
||||||
'ansible_version': get_ansible_version(),
|
|
||||||
'license_type': license_info.get('license_type', 'UNLICENSED'),
|
'license_type': license_info.get('license_type', 'UNLICENSED'),
|
||||||
'free_instances': license_info.get('free_instances', 0),
|
'free_instances': license_info.get('free_instances', 0),
|
||||||
'total_licensed_instances': license_info.get('instance_count', 0),
|
'total_licensed_instances': license_info.get('instance_count', 0),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from prometheus_client import REGISTRY, PROCESS_COLLECTOR, PLATFORM_COLLECTOR, GC_COLLECTOR, Gauge, Info, generate_latest
|
from prometheus_client import PROCESS_COLLECTOR, PLATFORM_COLLECTOR, GC_COLLECTOR, CollectorRegistry, Gauge, Info, generate_latest
|
||||||
|
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
from awx.main.utils import get_awx_version, get_ansible_version
|
from awx.main.utils import get_awx_version
|
||||||
from awx.main.analytics.collectors import (
|
from awx.main.analytics.collectors import (
|
||||||
counts,
|
counts,
|
||||||
instance_info,
|
instance_info,
|
||||||
@@ -11,115 +11,123 @@ from awx.main.analytics.collectors import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
REGISTRY.unregister(PROCESS_COLLECTOR)
|
|
||||||
REGISTRY.unregister(PLATFORM_COLLECTOR)
|
|
||||||
REGISTRY.unregister(GC_COLLECTOR)
|
|
||||||
|
|
||||||
SYSTEM_INFO = Info('awx_system', 'AWX System Information')
|
|
||||||
ORG_COUNT = Gauge('awx_organizations_total', 'Number of organizations')
|
|
||||||
USER_COUNT = Gauge('awx_users_total', 'Number of users')
|
|
||||||
TEAM_COUNT = Gauge('awx_teams_total', 'Number of teams')
|
|
||||||
INV_COUNT = Gauge('awx_inventories_total', 'Number of inventories')
|
|
||||||
PROJ_COUNT = Gauge('awx_projects_total', 'Number of projects')
|
|
||||||
JT_COUNT = Gauge('awx_job_templates_total', 'Number of job templates')
|
|
||||||
WFJT_COUNT = Gauge('awx_workflow_job_templates_total', 'Number of workflow job templates')
|
|
||||||
HOST_COUNT = Gauge(
|
|
||||||
'awx_hosts_total',
|
|
||||||
'Number of hosts',
|
|
||||||
[
|
|
||||||
'type',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
SCHEDULE_COUNT = Gauge('awx_schedules_total', 'Number of schedules')
|
|
||||||
INV_SCRIPT_COUNT = Gauge('awx_inventory_scripts_total', 'Number of invetory scripts')
|
|
||||||
USER_SESSIONS = Gauge(
|
|
||||||
'awx_sessions_total',
|
|
||||||
'Number of sessions',
|
|
||||||
[
|
|
||||||
'type',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
CUSTOM_VENVS = Gauge('awx_custom_virtualenvs_total', 'Number of virtualenvs')
|
|
||||||
RUNNING_JOBS = Gauge('awx_running_jobs_total', 'Number of running jobs on the Tower system')
|
|
||||||
PENDING_JOBS = Gauge('awx_pending_jobs_total', 'Number of pending jobs on the Tower system')
|
|
||||||
STATUS = Gauge(
|
|
||||||
'awx_status_total',
|
|
||||||
'Status of Job launched',
|
|
||||||
[
|
|
||||||
'status',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
INSTANCE_CAPACITY = Gauge(
|
|
||||||
'awx_instance_capacity',
|
|
||||||
'Capacity of each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_CPU = Gauge(
|
|
||||||
'awx_instance_cpu',
|
|
||||||
'CPU cores on each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_MEMORY = Gauge(
|
|
||||||
'awx_instance_memory',
|
|
||||||
'RAM (Kb) on each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_INFO = Info(
|
|
||||||
'awx_instance',
|
|
||||||
'Info about each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_LAUNCH_TYPE = Gauge(
|
|
||||||
'awx_instance_launch_type_total',
|
|
||||||
'Type of Job launched',
|
|
||||||
[
|
|
||||||
'node',
|
|
||||||
'launch_type',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_STATUS = Gauge(
|
|
||||||
'awx_instance_status_total',
|
|
||||||
'Status of Job launched',
|
|
||||||
[
|
|
||||||
'node',
|
|
||||||
'status',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_CONSUMED_CAPACITY = Gauge(
|
|
||||||
'awx_instance_consumed_capacity',
|
|
||||||
'Consumed capacity of each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
INSTANCE_REMAINING_CAPACITY = Gauge(
|
|
||||||
'awx_instance_remaining_capacity',
|
|
||||||
'Remaining capacity of each node in a Tower system',
|
|
||||||
[
|
|
||||||
'hostname',
|
|
||||||
'instance_uuid',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license')
|
|
||||||
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license')
|
|
||||||
|
|
||||||
|
|
||||||
def metrics():
|
def metrics():
|
||||||
|
REGISTRY = CollectorRegistry()
|
||||||
|
|
||||||
|
SYSTEM_INFO = Info('awx_system', 'AWX System Information', registry=REGISTRY)
|
||||||
|
ORG_COUNT = Gauge('awx_organizations_total', 'Number of organizations', registry=REGISTRY)
|
||||||
|
USER_COUNT = Gauge('awx_users_total', 'Number of users', registry=REGISTRY)
|
||||||
|
TEAM_COUNT = Gauge('awx_teams_total', 'Number of teams', registry=REGISTRY)
|
||||||
|
INV_COUNT = Gauge('awx_inventories_total', 'Number of inventories', registry=REGISTRY)
|
||||||
|
PROJ_COUNT = Gauge('awx_projects_total', 'Number of projects', registry=REGISTRY)
|
||||||
|
JT_COUNT = Gauge('awx_job_templates_total', 'Number of job templates', registry=REGISTRY)
|
||||||
|
WFJT_COUNT = Gauge('awx_workflow_job_templates_total', 'Number of workflow job templates', registry=REGISTRY)
|
||||||
|
HOST_COUNT = Gauge(
|
||||||
|
'awx_hosts_total',
|
||||||
|
'Number of hosts',
|
||||||
|
[
|
||||||
|
'type',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
SCHEDULE_COUNT = Gauge('awx_schedules_total', 'Number of schedules', registry=REGISTRY)
|
||||||
|
INV_SCRIPT_COUNT = Gauge('awx_inventory_scripts_total', 'Number of invetory scripts', registry=REGISTRY)
|
||||||
|
USER_SESSIONS = Gauge(
|
||||||
|
'awx_sessions_total',
|
||||||
|
'Number of sessions',
|
||||||
|
[
|
||||||
|
'type',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
CUSTOM_VENVS = Gauge('awx_custom_virtualenvs_total', 'Number of virtualenvs', registry=REGISTRY)
|
||||||
|
RUNNING_JOBS = Gauge('awx_running_jobs_total', 'Number of running jobs on the Tower system', registry=REGISTRY)
|
||||||
|
PENDING_JOBS = Gauge('awx_pending_jobs_total', 'Number of pending jobs on the Tower system', registry=REGISTRY)
|
||||||
|
STATUS = Gauge(
|
||||||
|
'awx_status_total',
|
||||||
|
'Status of Job launched',
|
||||||
|
[
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
INSTANCE_CAPACITY = Gauge(
|
||||||
|
'awx_instance_capacity',
|
||||||
|
'Capacity of each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_CPU = Gauge(
|
||||||
|
'awx_instance_cpu',
|
||||||
|
'CPU cores on each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_MEMORY = Gauge(
|
||||||
|
'awx_instance_memory',
|
||||||
|
'RAM (Kb) on each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_INFO = Info(
|
||||||
|
'awx_instance',
|
||||||
|
'Info about each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_LAUNCH_TYPE = Gauge(
|
||||||
|
'awx_instance_launch_type_total',
|
||||||
|
'Type of Job launched',
|
||||||
|
[
|
||||||
|
'node',
|
||||||
|
'launch_type',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_STATUS = Gauge(
|
||||||
|
'awx_instance_status_total',
|
||||||
|
'Status of Job launched',
|
||||||
|
[
|
||||||
|
'node',
|
||||||
|
'status',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_CONSUMED_CAPACITY = Gauge(
|
||||||
|
'awx_instance_consumed_capacity',
|
||||||
|
'Consumed capacity of each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
INSTANCE_REMAINING_CAPACITY = Gauge(
|
||||||
|
'awx_instance_remaining_capacity',
|
||||||
|
'Remaining capacity of each node in a Tower system',
|
||||||
|
[
|
||||||
|
'hostname',
|
||||||
|
'instance_uuid',
|
||||||
|
],
|
||||||
|
registry=REGISTRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
LICENSE_INSTANCE_TOTAL = Gauge('awx_license_instance_total', 'Total number of managed hosts provided by your license', registry=REGISTRY)
|
||||||
|
LICENSE_INSTANCE_FREE = Gauge('awx_license_instance_free', 'Number of remaining managed hosts provided by your license', registry=REGISTRY)
|
||||||
|
|
||||||
license_info = get_license()
|
license_info = get_license()
|
||||||
SYSTEM_INFO.info(
|
SYSTEM_INFO.info(
|
||||||
{
|
{
|
||||||
@@ -127,7 +135,6 @@ def metrics():
|
|||||||
'insights_analytics': str(settings.INSIGHTS_TRACKING_STATE),
|
'insights_analytics': str(settings.INSIGHTS_TRACKING_STATE),
|
||||||
'tower_url_base': settings.TOWER_URL_BASE,
|
'tower_url_base': settings.TOWER_URL_BASE,
|
||||||
'tower_version': get_awx_version(),
|
'tower_version': get_awx_version(),
|
||||||
'ansible_version': get_ansible_version(),
|
|
||||||
'license_type': license_info.get('license_type', 'UNLICENSED'),
|
'license_type': license_info.get('license_type', 'UNLICENSED'),
|
||||||
'license_expiry': str(license_info.get('time_remaining', 0)),
|
'license_expiry': str(license_info.get('time_remaining', 0)),
|
||||||
'pendo_tracking': settings.PENDO_TRACKING_STATE,
|
'pendo_tracking': settings.PENDO_TRACKING_STATE,
|
||||||
@@ -197,7 +204,7 @@ def metrics():
|
|||||||
for status, value in statuses.items():
|
for status, value in statuses.items():
|
||||||
INSTANCE_STATUS.labels(node=node, status=status).set(value)
|
INSTANCE_STATUS.labels(node=node, status=status).set(value)
|
||||||
|
|
||||||
return generate_latest()
|
return generate_latest(registry=REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['metrics']
|
__all__ = ['metrics']
|
||||||
|
|||||||
304
awx/main/analytics/subsystem_metrics.py
Normal file
304
awx/main/analytics/subsystem_metrics.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.apps import apps
|
||||||
|
from awx.main.consumers import emit_channel_notification
|
||||||
|
|
||||||
|
root_key = 'awx_metrics'
|
||||||
|
logger = logging.getLogger('awx.main.wsbroadcast')
|
||||||
|
|
||||||
|
|
||||||
|
class BaseM:
|
||||||
|
def __init__(self, field, help_text):
|
||||||
|
self.field = field
|
||||||
|
self.help_text = help_text
|
||||||
|
self.current_value = 0
|
||||||
|
|
||||||
|
def clear_value(self, conn):
|
||||||
|
conn.hset(root_key, self.field, 0)
|
||||||
|
self.current_value = 0
|
||||||
|
|
||||||
|
def inc(self, value):
|
||||||
|
self.current_value += value
|
||||||
|
|
||||||
|
def set(self, value):
|
||||||
|
self.current_value = value
|
||||||
|
|
||||||
|
def decode(self, conn):
|
||||||
|
value = conn.hget(root_key, self.field)
|
||||||
|
return self.decode_value(value)
|
||||||
|
|
||||||
|
def to_prometheus(self, instance_data):
|
||||||
|
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} gauge\n"
|
||||||
|
for instance in instance_data:
|
||||||
|
output_text += f'{self.field}{{node="{instance}"}} {instance_data[instance][self.field]}\n'
|
||||||
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
|
class FloatM(BaseM):
|
||||||
|
def decode_value(self, value):
|
||||||
|
if value is not None:
|
||||||
|
return float(value)
|
||||||
|
else:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def store_value(self, conn):
|
||||||
|
conn.hincrbyfloat(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
|
||||||
|
|
||||||
|
class IntM(BaseM):
|
||||||
|
def decode_value(self, value):
|
||||||
|
if value is not None:
|
||||||
|
return int(value)
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def store_value(self, conn):
|
||||||
|
conn.hincrby(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SetIntM(BaseM):
|
||||||
|
def decode_value(self, value):
|
||||||
|
if value is not None:
|
||||||
|
return int(value)
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def store_value(self, conn):
|
||||||
|
# do not set value if it has not changed since last time this was called
|
||||||
|
if self.current_value is not None:
|
||||||
|
conn.hset(root_key, self.field, self.current_value)
|
||||||
|
self.current_value = None
|
||||||
|
|
||||||
|
|
||||||
|
class SetFloatM(SetIntM):
|
||||||
|
def decode_value(self, value):
|
||||||
|
if value is not None:
|
||||||
|
return float(value)
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class HistogramM(BaseM):
|
||||||
|
def __init__(self, field, help_text, buckets):
|
||||||
|
self.buckets = buckets
|
||||||
|
self.buckets_to_keys = {}
|
||||||
|
for b in buckets:
|
||||||
|
self.buckets_to_keys[b] = IntM(field + '_' + str(b), '')
|
||||||
|
self.inf = IntM(field + '_inf', '')
|
||||||
|
self.sum = IntM(field + '_sum', '')
|
||||||
|
super(HistogramM, self).__init__(field, help_text)
|
||||||
|
|
||||||
|
def clear_value(self, conn):
|
||||||
|
conn.hset(root_key, self.field, 0)
|
||||||
|
self.inf.clear_value(conn)
|
||||||
|
self.sum.clear_value(conn)
|
||||||
|
for b in self.buckets_to_keys.values():
|
||||||
|
b.clear_value(conn)
|
||||||
|
super(HistogramM, self).clear_value(conn)
|
||||||
|
|
||||||
|
def observe(self, value):
|
||||||
|
for b in self.buckets:
|
||||||
|
if value <= b:
|
||||||
|
self.buckets_to_keys[b].inc(1)
|
||||||
|
break
|
||||||
|
self.sum.inc(value)
|
||||||
|
self.inf.inc(1)
|
||||||
|
|
||||||
|
def decode(self, conn):
|
||||||
|
values = {'counts': []}
|
||||||
|
for b in self.buckets_to_keys:
|
||||||
|
values['counts'].append(self.buckets_to_keys[b].decode(conn))
|
||||||
|
values['sum'] = self.sum.decode(conn)
|
||||||
|
values['inf'] = self.inf.decode(conn)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def store_value(self, conn):
|
||||||
|
for b in self.buckets:
|
||||||
|
self.buckets_to_keys[b].store_value(conn)
|
||||||
|
self.sum.store_value(conn)
|
||||||
|
self.inf.store_value(conn)
|
||||||
|
|
||||||
|
def to_prometheus(self, instance_data):
|
||||||
|
output_text = f"# HELP {self.field} {self.help_text}\n# TYPE {self.field} histogram\n"
|
||||||
|
for instance in instance_data:
|
||||||
|
for i, b in enumerate(self.buckets):
|
||||||
|
output_text += f'{self.field}_bucket{{le="{b}",node="{instance}"}} {sum(instance_data[instance][self.field]["counts"][0:i+1])}\n'
|
||||||
|
output_text += f'{self.field}_bucket{{le="+Inf",node="{instance}"}} {instance_data[instance][self.field]["inf"]}\n'
|
||||||
|
output_text += f'{self.field}_count{{node="{instance}"}} {instance_data[instance][self.field]["inf"]}\n'
|
||||||
|
output_text += f'{self.field}_sum{{node="{instance}"}} {instance_data[instance][self.field]["sum"]}\n'
|
||||||
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
|
class Metrics:
|
||||||
|
def __init__(self, auto_pipe_execute=True):
|
||||||
|
self.pipe = redis.Redis.from_url(settings.BROKER_URL).pipeline()
|
||||||
|
self.conn = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
|
self.last_pipe_execute = time.time()
|
||||||
|
# track if metrics have been modified since last saved to redis
|
||||||
|
# start with True so that we get an initial save to redis
|
||||||
|
self.metrics_have_changed = True
|
||||||
|
self.pipe_execute_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS
|
||||||
|
self.send_metrics_interval = settings.SUBSYSTEM_METRICS_INTERVAL_SEND_METRICS
|
||||||
|
# auto pipe execute will commit transaction of metric data to redis
|
||||||
|
# at a regular interval (pipe_execute_interval). If set to False,
|
||||||
|
# the calling function should call .pipe_execute() explicitly
|
||||||
|
self.auto_pipe_execute = auto_pipe_execute
|
||||||
|
Instance = apps.get_model('main', 'Instance')
|
||||||
|
self.instance_name = Instance.objects.me().hostname
|
||||||
|
|
||||||
|
# metric name, help_text
|
||||||
|
METRICSLIST = [
|
||||||
|
SetIntM('callback_receiver_events_queue_size_redis', 'Current number of events in redis queue'),
|
||||||
|
IntM('callback_receiver_events_popped_redis', 'Number of events popped from redis'),
|
||||||
|
IntM('callback_receiver_events_in_memory', 'Current number of events in memory (in transfer from redis to db)'),
|
||||||
|
IntM('callback_receiver_batch_events_errors', 'Number of times batch insertion failed'),
|
||||||
|
FloatM('callback_receiver_events_insert_db_seconds', 'Time spent saving events to database'),
|
||||||
|
IntM('callback_receiver_events_insert_db', 'Number of events batch inserted into database'),
|
||||||
|
HistogramM(
|
||||||
|
'callback_receiver_batch_events_insert_db', 'Number of events batch inserted into database', settings.SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS
|
||||||
|
),
|
||||||
|
FloatM('subsystem_metrics_pipe_execute_seconds', 'Time spent saving metrics to redis'),
|
||||||
|
IntM('subsystem_metrics_pipe_execute_calls', 'Number of calls to pipe_execute'),
|
||||||
|
FloatM('subsystem_metrics_send_metrics_seconds', 'Time spent sending metrics to other nodes'),
|
||||||
|
]
|
||||||
|
# turn metric list into dictionary with the metric name as a key
|
||||||
|
self.METRICS = {}
|
||||||
|
for m in METRICSLIST:
|
||||||
|
self.METRICS[m.field] = m
|
||||||
|
|
||||||
|
# track last time metrics were sent to other nodes
|
||||||
|
self.previous_send_metrics = SetFloatM('send_metrics_time', 'Timestamp of previous send_metrics call')
|
||||||
|
|
||||||
|
def clear_values(self):
|
||||||
|
for m in self.METRICS.values():
|
||||||
|
m.clear_value(self.conn)
|
||||||
|
self.metrics_have_changed = True
|
||||||
|
self.conn.delete(root_key + "_lock")
|
||||||
|
|
||||||
|
def inc(self, field, value):
|
||||||
|
if value != 0:
|
||||||
|
self.METRICS[field].inc(value)
|
||||||
|
self.metrics_have_changed = True
|
||||||
|
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
||||||
|
self.pipe_execute()
|
||||||
|
|
||||||
|
def set(self, field, value):
|
||||||
|
self.METRICS[field].set(value)
|
||||||
|
self.metrics_have_changed = True
|
||||||
|
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
||||||
|
self.pipe_execute()
|
||||||
|
|
||||||
|
def observe(self, field, value):
|
||||||
|
self.METRICS[field].observe(value)
|
||||||
|
self.metrics_have_changed = True
|
||||||
|
if self.auto_pipe_execute is True and self.should_pipe_execute() is True:
|
||||||
|
self.pipe_execute()
|
||||||
|
|
||||||
|
def serialize_local_metrics(self):
|
||||||
|
data = self.load_local_metrics()
|
||||||
|
return json.dumps(data)
|
||||||
|
|
||||||
|
def load_local_metrics(self):
|
||||||
|
# generate python dictionary of key values from metrics stored in redis
|
||||||
|
data = {}
|
||||||
|
for field in self.METRICS:
|
||||||
|
data[field] = self.METRICS[field].decode(self.conn)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def store_metrics(self, data_json):
|
||||||
|
# called when receiving metrics from other instances
|
||||||
|
data = json.loads(data_json)
|
||||||
|
if self.instance_name != data['instance']:
|
||||||
|
logger.debug(f"{self.instance_name} received subsystem metrics from {data['instance']}")
|
||||||
|
self.conn.set(root_key + "_instance_" + data['instance'], data['metrics'])
|
||||||
|
|
||||||
|
def should_pipe_execute(self):
|
||||||
|
if self.metrics_have_changed is False:
|
||||||
|
return False
|
||||||
|
if time.time() - self.last_pipe_execute > self.pipe_execute_interval:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def pipe_execute(self):
|
||||||
|
if self.metrics_have_changed is True:
|
||||||
|
duration_to_save = time.perf_counter()
|
||||||
|
for m in self.METRICS:
|
||||||
|
self.METRICS[m].store_value(self.pipe)
|
||||||
|
self.pipe.execute()
|
||||||
|
self.last_pipe_execute = time.time()
|
||||||
|
self.metrics_have_changed = False
|
||||||
|
duration_to_save = time.perf_counter() - duration_to_save
|
||||||
|
self.METRICS['subsystem_metrics_pipe_execute_seconds'].inc(duration_to_save)
|
||||||
|
self.METRICS['subsystem_metrics_pipe_execute_calls'].inc(1)
|
||||||
|
|
||||||
|
duration_to_save = time.perf_counter()
|
||||||
|
self.send_metrics()
|
||||||
|
duration_to_save = time.perf_counter() - duration_to_save
|
||||||
|
self.METRICS['subsystem_metrics_send_metrics_seconds'].inc(duration_to_save)
|
||||||
|
|
||||||
|
def send_metrics(self):
|
||||||
|
# more than one thread could be calling this at the same time, so should
|
||||||
|
# get acquire redis lock before sending metrics
|
||||||
|
lock = self.conn.lock(root_key + '_lock', thread_local=False)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.previous_send_metrics.decode(self.conn) > self.send_metrics_interval:
|
||||||
|
payload = {
|
||||||
|
'instance': self.instance_name,
|
||||||
|
'metrics': self.serialize_local_metrics(),
|
||||||
|
}
|
||||||
|
# store a local copy as well
|
||||||
|
self.store_metrics(json.dumps(payload))
|
||||||
|
emit_channel_notification("metrics", payload)
|
||||||
|
self.previous_send_metrics.set(current_time)
|
||||||
|
self.previous_send_metrics.store_value(self.conn)
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
def load_other_metrics(self, request):
|
||||||
|
# data received from other nodes are stored in their own keys
|
||||||
|
# e.g., awx_metrics_instance_awx-1, awx_metrics_instance_awx-2
|
||||||
|
# this method looks for keys with "_instance_" in the name and loads the data
|
||||||
|
# also filters data based on request query params
|
||||||
|
# if additional filtering is added, update metrics_view.md
|
||||||
|
instances_filter = request.query_params.getlist("node")
|
||||||
|
# get a sorted list of instance names
|
||||||
|
instance_names = [self.instance_name]
|
||||||
|
for m in self.conn.scan_iter(root_key + '_instance_*'):
|
||||||
|
instance_names.append(m.decode('UTF-8').split('_instance_')[1])
|
||||||
|
instance_names.sort()
|
||||||
|
# load data, including data from the this local instance
|
||||||
|
instance_data = {}
|
||||||
|
for instance in instance_names:
|
||||||
|
if len(instances_filter) == 0 or instance in instances_filter:
|
||||||
|
instance_data_from_redis = self.conn.get(root_key + '_instance_' + instance)
|
||||||
|
# data from other instances may not be available. That is OK.
|
||||||
|
if instance_data_from_redis:
|
||||||
|
instance_data[instance] = json.loads(instance_data_from_redis.decode('UTF-8'))
|
||||||
|
return instance_data
|
||||||
|
|
||||||
|
def generate_metrics(self, request):
|
||||||
|
# takes the api request, filters, and generates prometheus data
|
||||||
|
# if additional filtering is added, update metrics_view.md
|
||||||
|
instance_data = self.load_other_metrics(request)
|
||||||
|
metrics_filter = request.query_params.getlist("metric")
|
||||||
|
output_text = ''
|
||||||
|
if instance_data:
|
||||||
|
for field in self.METRICS:
|
||||||
|
if len(metrics_filter) == 0 or field in metrics_filter:
|
||||||
|
output_text += self.METRICS[field].to_prometheus(instance_data)
|
||||||
|
return output_text
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(request):
|
||||||
|
m = Metrics()
|
||||||
|
return m.generate_metrics(request)
|
||||||
@@ -186,7 +186,7 @@ register(
|
|||||||
default=None,
|
default=None,
|
||||||
queryset=ExecutionEnvironment.objects.all(),
|
queryset=ExecutionEnvironment.objects.all(),
|
||||||
label=_('Global default execution environment'),
|
label=_('Global default execution environment'),
|
||||||
help_text=_('.'),
|
help_text=_('The Execution Environment to be used when one has not been configured for a job template.'),
|
||||||
category=_('System'),
|
category=_('System'),
|
||||||
category_slug='system',
|
category_slug='system',
|
||||||
)
|
)
|
||||||
@@ -233,16 +233,7 @@ register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
register(
|
register(
|
||||||
'AWX_PROOT_ENABLED',
|
'AWX_ISOLATION_BASE_PATH',
|
||||||
field_class=fields.BooleanField,
|
|
||||||
label=_('Enable job isolation'),
|
|
||||||
help_text=_('Isolates an Ansible job from protected parts of the system to prevent exposing sensitive information.'),
|
|
||||||
category=_('Jobs'),
|
|
||||||
category_slug='jobs',
|
|
||||||
)
|
|
||||||
|
|
||||||
register(
|
|
||||||
'AWX_PROOT_BASE_PATH',
|
|
||||||
field_class=fields.CharField,
|
field_class=fields.CharField,
|
||||||
label=_('Job execution path'),
|
label=_('Job execution path'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
@@ -255,17 +246,7 @@ register(
|
|||||||
)
|
)
|
||||||
|
|
||||||
register(
|
register(
|
||||||
'AWX_PROOT_HIDE_PATHS',
|
'AWX_ISOLATION_SHOW_PATHS',
|
||||||
field_class=fields.StringListField,
|
|
||||||
required=False,
|
|
||||||
label=_('Paths to hide from isolated jobs'),
|
|
||||||
help_text=_('Additional paths to hide from isolated processes. Enter one path per line.'),
|
|
||||||
category=_('Jobs'),
|
|
||||||
category_slug='jobs',
|
|
||||||
)
|
|
||||||
|
|
||||||
register(
|
|
||||||
'AWX_PROOT_SHOW_PATHS',
|
|
||||||
field_class=fields.StringListField,
|
field_class=fields.StringListField,
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Paths to expose to isolated jobs'),
|
label=_('Paths to expose to isolated jobs'),
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ ENV_BLOCKLIST = frozenset(
|
|||||||
'VIRTUAL_ENV',
|
'VIRTUAL_ENV',
|
||||||
'PATH',
|
'PATH',
|
||||||
'PYTHONPATH',
|
'PYTHONPATH',
|
||||||
'PROOT_TMP_DIR',
|
|
||||||
'JOB_ID',
|
'JOB_ID',
|
||||||
'INVENTORY_ID',
|
'INVENTORY_ID',
|
||||||
'INVENTORY_SOURCE_ID',
|
'INVENTORY_SOURCE_ID',
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
|||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.consumers')
|
logger = logging.getLogger('awx.main.consumers')
|
||||||
XRF_KEY = '_auth_user_xrf'
|
XRF_KEY = '_auth_user_xrf'
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from awx.main.models import JobEvent, AdHocCommandEvent, ProjectUpdateEvent, Inv
|
|||||||
from awx.main.tasks import handle_success_and_failure_notifications
|
from awx.main.tasks import handle_success_and_failure_notifications
|
||||||
from awx.main.models.events import emit_event_detail
|
from awx.main.models.events import emit_event_detail
|
||||||
from awx.main.utils.profiling import AWXProfiler
|
from awx.main.utils.profiling import AWXProfiler
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
from .base import BaseWorker
|
from .base import BaseWorker
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
logger = logging.getLogger('awx.main.commands.run_callback_receiver')
|
||||||
@@ -46,16 +46,22 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
self.buff = {}
|
self.buff = {}
|
||||||
self.pid = os.getpid()
|
self.pid = os.getpid()
|
||||||
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
self.redis = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics(auto_pipe_execute=False)
|
||||||
|
self.queue_pop = 0
|
||||||
|
self.queue_name = settings.CALLBACK_QUEUE
|
||||||
self.prof = AWXProfiler("CallbackBrokerWorker")
|
self.prof = AWXProfiler("CallbackBrokerWorker")
|
||||||
for key in self.redis.keys('awx_callback_receiver_statistics_*'):
|
for key in self.redis.keys('awx_callback_receiver_statistics_*'):
|
||||||
self.redis.delete(key)
|
self.redis.delete(key)
|
||||||
|
|
||||||
def read(self, queue):
|
def read(self, queue):
|
||||||
try:
|
try:
|
||||||
res = self.redis.blpop(settings.CALLBACK_QUEUE, timeout=1)
|
res = self.redis.blpop(self.queue_name, timeout=1)
|
||||||
if res is None:
|
if res is None:
|
||||||
return {'event': 'FLUSH'}
|
return {'event': 'FLUSH'}
|
||||||
self.total += 1
|
self.total += 1
|
||||||
|
self.queue_pop += 1
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_popped_redis', 1)
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', 1)
|
||||||
return json.loads(res[1])
|
return json.loads(res[1])
|
||||||
except redis.exceptions.RedisError:
|
except redis.exceptions.RedisError:
|
||||||
logger.exception("encountered an error communicating with redis")
|
logger.exception("encountered an error communicating with redis")
|
||||||
@@ -64,8 +70,19 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
logger.exception("failed to decode JSON message from redis")
|
logger.exception("failed to decode JSON message from redis")
|
||||||
finally:
|
finally:
|
||||||
self.record_statistics()
|
self.record_statistics()
|
||||||
|
self.record_read_metrics()
|
||||||
|
|
||||||
return {'event': 'FLUSH'}
|
return {'event': 'FLUSH'}
|
||||||
|
|
||||||
|
def record_read_metrics(self):
|
||||||
|
if self.queue_pop == 0:
|
||||||
|
return
|
||||||
|
if self.subsystem_metrics.should_pipe_execute() is True:
|
||||||
|
queue_size = self.redis.llen(self.queue_name)
|
||||||
|
self.subsystem_metrics.set('callback_receiver_events_queue_size_redis', queue_size)
|
||||||
|
self.subsystem_metrics.pipe_execute()
|
||||||
|
self.queue_pop = 0
|
||||||
|
|
||||||
def record_statistics(self):
|
def record_statistics(self):
|
||||||
# buffer stat recording to once per (by default) 5s
|
# buffer stat recording to once per (by default) 5s
|
||||||
if time.time() - self.last_stats > settings.JOB_EVENT_STATISTICS_INTERVAL:
|
if time.time() - self.last_stats > settings.JOB_EVENT_STATISTICS_INTERVAL:
|
||||||
@@ -99,27 +116,44 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
def flush(self, force=False):
|
def flush(self, force=False):
|
||||||
now = tz_now()
|
now = tz_now()
|
||||||
if force or (time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or any([len(events) >= 1000 for events in self.buff.values()]):
|
if force or (time.time() - self.last_flush) > settings.JOB_EVENT_BUFFER_SECONDS or any([len(events) >= 1000 for events in self.buff.values()]):
|
||||||
|
bulk_events_saved = 0
|
||||||
|
singular_events_saved = 0
|
||||||
|
metrics_events_batch_save_errors = 0
|
||||||
for cls, events in self.buff.items():
|
for cls, events in self.buff.items():
|
||||||
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
logger.debug(f'{cls.__name__}.objects.bulk_create({len(events)})')
|
||||||
for e in events:
|
for e in events:
|
||||||
if not e.created:
|
if not e.created:
|
||||||
e.created = now
|
e.created = now
|
||||||
e.modified = now
|
e.modified = now
|
||||||
|
duration_to_save = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
cls.objects.bulk_create(events)
|
cls.objects.bulk_create(events)
|
||||||
|
bulk_events_saved += len(events)
|
||||||
except Exception:
|
except Exception:
|
||||||
# if an exception occurs, we should re-attempt to save the
|
# if an exception occurs, we should re-attempt to save the
|
||||||
# events one-by-one, because something in the list is
|
# events one-by-one, because something in the list is
|
||||||
# broken/stale
|
# broken/stale
|
||||||
|
metrics_events_batch_save_errors += 1
|
||||||
for e in events:
|
for e in events:
|
||||||
try:
|
try:
|
||||||
e.save()
|
e.save()
|
||||||
|
singular_events_saved += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Database Error Saving Job Event')
|
logger.exception('Database Error Saving Job Event')
|
||||||
|
duration_to_save = time.perf_counter() - duration_to_save
|
||||||
for e in events:
|
for e in events:
|
||||||
emit_event_detail(e)
|
emit_event_detail(e)
|
||||||
self.buff = {}
|
self.buff = {}
|
||||||
self.last_flush = time.time()
|
self.last_flush = time.time()
|
||||||
|
# only update metrics if we saved events
|
||||||
|
if (bulk_events_saved + singular_events_saved) > 0:
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_batch_events_errors', metrics_events_batch_save_errors)
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_insert_db_seconds', duration_to_save)
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_insert_db', bulk_events_saved + singular_events_saved)
|
||||||
|
self.subsystem_metrics.observe('callback_receiver_batch_events_insert_db', bulk_events_saved)
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -(bulk_events_saved + singular_events_saved))
|
||||||
|
if self.subsystem_metrics.should_pipe_execute() is True:
|
||||||
|
self.subsystem_metrics.pipe_execute()
|
||||||
|
|
||||||
def perform_work(self, body):
|
def perform_work(self, body):
|
||||||
try:
|
try:
|
||||||
@@ -169,6 +203,7 @@ class CallbackBrokerWorker(BaseWorker):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception('Worker failed to emit notifications: Job {}'.format(job_identifier))
|
logger.exception('Worker failed to emit notifications: Job {}'.format(job_identifier))
|
||||||
finally:
|
finally:
|
||||||
|
self.subsystem_metrics.inc('callback_receiver_events_in_memory', -1)
|
||||||
GuidMiddleware.set_guid('')
|
GuidMiddleware.set_guid('')
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class IsolatedManager(object):
|
|||||||
|
|
||||||
extravars = {
|
extravars = {
|
||||||
'src': self.private_data_dir,
|
'src': self.private_data_dir,
|
||||||
'dest': settings.AWX_PROOT_BASE_PATH,
|
'dest': settings.AWX_ISOLATION_BASE_PATH,
|
||||||
'ident': self.ident,
|
'ident': self.ident,
|
||||||
'job_id': self.instance.id,
|
'job_id': self.instance.id,
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ class IsolatedManager(object):
|
|||||||
if not len(instance_qs):
|
if not len(instance_qs):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
private_data_dir = tempfile.mkdtemp(prefix='awx_iso_heartbeat_', dir=settings.AWX_PROOT_BASE_PATH)
|
private_data_dir = tempfile.mkdtemp(prefix='awx_iso_heartbeat_', dir=settings.AWX_ISOLATION_BASE_PATH)
|
||||||
self.runner_params = self.build_runner_params([instance.hostname for instance in instance_qs])
|
self.runner_params = self.build_runner_params([instance.hostname for instance in instance_qs])
|
||||||
self.runner_params['private_data_dir'] = private_data_dir
|
self.runner_params['private_data_dir'] = private_data_dir
|
||||||
self.runner_params['forks'] = len(instance_qs)
|
self.runner_params['forks'] = len(instance_qs)
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ class AnsibleInventoryLoader(object):
|
|||||||
def __init__(self, source, venv_path=None, verbosity=0):
|
def __init__(self, source, venv_path=None, verbosity=0):
|
||||||
self.source = source
|
self.source = source
|
||||||
self.verbosity = verbosity
|
self.verbosity = verbosity
|
||||||
# TODO: remove once proot has been removed
|
|
||||||
self.tmp_private_dir = None
|
|
||||||
if venv_path:
|
if venv_path:
|
||||||
self.venv_path = venv_path
|
self.venv_path = venv_path
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class Command(BaseCommand):
|
|||||||
raise CommandError("--hostname is a required argument")
|
raise CommandError("--hostname is a required argument")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = tempfile.mkdtemp(prefix='awx_isolated_ssh', dir=settings.AWX_PROOT_BASE_PATH)
|
path = tempfile.mkdtemp(prefix='awx_isolated_ssh', dir=settings.AWX_ISOLATION_BASE_PATH)
|
||||||
ssh_key = None
|
ssh_key = None
|
||||||
if all([getattr(settings, 'AWX_ISOLATED_KEY_GENERATION', False) is True, getattr(settings, 'AWX_ISOLATED_PRIVATE_KEY', None)]):
|
if all([getattr(settings, 'AWX_ISOLATED_KEY_GENERATION', False) is True, getattr(settings, 'AWX_ISOLATED_PRIVATE_KEY', None)]):
|
||||||
ssh_key = settings.AWX_ISOLATED_PRIVATE_KEY
|
ssh_key = settings.AWX_ISOLATED_PRIVATE_KEY
|
||||||
|
|||||||
@@ -466,10 +466,14 @@ class CredentialType(CommonModelNameNotUnique):
|
|||||||
if len(value):
|
if len(value):
|
||||||
namespace[field_name] = value
|
namespace[field_name] = value
|
||||||
|
|
||||||
# default missing boolean fields to False
|
|
||||||
for field in self.inputs.get('fields', []):
|
for field in self.inputs.get('fields', []):
|
||||||
|
# default missing boolean fields to False
|
||||||
if field['type'] == 'boolean' and field['id'] not in credential.inputs.keys():
|
if field['type'] == 'boolean' and field['id'] not in credential.inputs.keys():
|
||||||
namespace[field['id']] = safe_namespace[field['id']] = False
|
namespace[field['id']] = safe_namespace[field['id']] = False
|
||||||
|
# make sure private keys end with a \n
|
||||||
|
if field.get('format') == 'ssh_private_key':
|
||||||
|
if field['id'] in namespace and not namespace[field['id']].endswith('\n'):
|
||||||
|
namespace[field['id']] += '\n'
|
||||||
|
|
||||||
file_tmpls = self.injectors.get('file', {})
|
file_tmpls = self.injectors.get('file', {})
|
||||||
# If any file templates are provided, render the files and update the
|
# If any file templates are provided, render the files and update the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import redis
|
|||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
|
|
||||||
__all__ = ['CallbackQueueDispatcher']
|
__all__ = ['CallbackQueueDispatcher']
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ class CallbackQueueDispatcher(object):
|
|||||||
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
self.queue = getattr(settings, 'CALLBACK_QUEUE', '')
|
||||||
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher')
|
||||||
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
self.connection = redis.Redis.from_url(settings.BROKER_URL)
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics()
|
||||||
|
|
||||||
def dispatch(self, obj):
|
def dispatch(self, obj):
|
||||||
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
self.connection.rpush(self.queue, json.dumps(obj, cls=AnsibleJSONEncoder))
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ from awx.main.consumers import emit_channel_notification
|
|||||||
from awx.main import analytics
|
from awx.main import analytics
|
||||||
from awx.conf import settings_registry
|
from awx.conf import settings_registry
|
||||||
from awx.conf.license import get_license
|
from awx.conf.license import get_license
|
||||||
|
from awx.main.analytics.subsystem_metrics import Metrics
|
||||||
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ def dispatch_startup():
|
|||||||
cluster_node_heartbeat()
|
cluster_node_heartbeat()
|
||||||
if Instance.objects.me().is_controller():
|
if Instance.objects.me().is_controller():
|
||||||
awx_isolated_heartbeat()
|
awx_isolated_heartbeat()
|
||||||
|
Metrics().clear_values()
|
||||||
|
|
||||||
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
# Update Tower's rsyslog.conf file based on loggins settings in the db
|
||||||
reconfigure_rsyslog()
|
reconfigure_rsyslog()
|
||||||
@@ -841,7 +843,6 @@ class BaseTask(object):
|
|||||||
model = None
|
model = None
|
||||||
event_model = None
|
event_model = None
|
||||||
abstract = True
|
abstract = True
|
||||||
proot_show_paths = []
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.cleanup_paths = []
|
self.cleanup_paths = []
|
||||||
@@ -908,9 +909,9 @@ class BaseTask(object):
|
|||||||
if pull:
|
if pull:
|
||||||
params['container_options'].append(f'--pull={pull}')
|
params['container_options'].append(f'--pull={pull}')
|
||||||
|
|
||||||
if settings.AWX_PROOT_SHOW_PATHS:
|
if settings.AWX_ISOLATION_SHOW_PATHS:
|
||||||
params['container_volume_mounts'] = []
|
params['container_volume_mounts'] = []
|
||||||
for this_path in settings.AWX_PROOT_SHOW_PATHS:
|
for this_path in settings.AWX_ISOLATION_SHOW_PATHS:
|
||||||
params['container_volume_mounts'].append(f'{this_path}:{this_path}:Z')
|
params['container_volume_mounts'].append(f'{this_path}:{this_path}:Z')
|
||||||
return params
|
return params
|
||||||
|
|
||||||
@@ -924,7 +925,7 @@ class BaseTask(object):
|
|||||||
"""
|
"""
|
||||||
Create a temporary directory for job-related files.
|
Create a temporary directory for job-related files.
|
||||||
"""
|
"""
|
||||||
pdd_wrapper_path = tempfile.mkdtemp(prefix=f'pdd_wrapper_{instance.pk}_', dir=settings.AWX_PROOT_BASE_PATH)
|
pdd_wrapper_path = tempfile.mkdtemp(prefix=f'pdd_wrapper_{instance.pk}_', dir=settings.AWX_ISOLATION_BASE_PATH)
|
||||||
os.chmod(pdd_wrapper_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
os.chmod(pdd_wrapper_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||||
if settings.AWX_CLEANUP_PATHS:
|
if settings.AWX_CLEANUP_PATHS:
|
||||||
self.cleanup_paths.append(pdd_wrapper_path)
|
self.cleanup_paths.append(pdd_wrapper_path)
|
||||||
@@ -1090,12 +1091,6 @@ class BaseTask(object):
|
|||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def should_use_proot(self, instance):
|
|
||||||
"""
|
|
||||||
Return whether this task should use proot.
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def build_inventory(self, instance, private_data_dir):
|
def build_inventory(self, instance, private_data_dir):
|
||||||
script_params = dict(hostvars=True, towervars=True)
|
script_params = dict(hostvars=True, towervars=True)
|
||||||
if hasattr(instance, 'job_slice_number'):
|
if hasattr(instance, 'job_slice_number'):
|
||||||
@@ -1371,8 +1366,8 @@ class BaseTask(object):
|
|||||||
status = self.instance.status
|
status = self.instance.status
|
||||||
raise RuntimeError('not starting %s task' % self.instance.status)
|
raise RuntimeError('not starting %s task' % self.instance.status)
|
||||||
|
|
||||||
if not os.path.exists(settings.AWX_PROOT_BASE_PATH):
|
if not os.path.exists(settings.AWX_ISOLATION_BASE_PATH):
|
||||||
raise RuntimeError('AWX_PROOT_BASE_PATH=%s does not exist' % settings.AWX_PROOT_BASE_PATH)
|
raise RuntimeError('AWX_ISOLATION_BASE_PATH=%s does not exist' % settings.AWX_ISOLATION_BASE_PATH)
|
||||||
|
|
||||||
# store a record of the venv used at runtime
|
# store a record of the venv used at runtime
|
||||||
if hasattr(self.instance, 'custom_virtualenv'):
|
if hasattr(self.instance, 'custom_virtualenv'):
|
||||||
@@ -1598,8 +1593,7 @@ class RunJob(BaseTask):
|
|||||||
env['ANSIBLE_CALLBACK_PLUGINS'] = ':'.join(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
|
env['ANSIBLE_CALLBACK_PLUGINS'] = ':'.join(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
|
||||||
env['AWX_HOST'] = settings.TOWER_URL_BASE
|
env['AWX_HOST'] = settings.TOWER_URL_BASE
|
||||||
|
|
||||||
# Create a directory for ControlPath sockets that is unique to each
|
# Create a directory for ControlPath sockets that is unique to each job
|
||||||
# job and visible inside the proot environment (when enabled).
|
|
||||||
cp_dir = os.path.join(private_data_dir, 'cp')
|
cp_dir = os.path.join(private_data_dir, 'cp')
|
||||||
if not os.path.exists(cp_dir):
|
if not os.path.exists(cp_dir):
|
||||||
os.mkdir(cp_dir, 0o700)
|
os.mkdir(cp_dir, 0o700)
|
||||||
@@ -1768,14 +1762,6 @@ class RunJob(BaseTask):
|
|||||||
"""
|
"""
|
||||||
return settings.AWX_RESOURCE_PROFILING_ENABLED
|
return settings.AWX_RESOURCE_PROFILING_ENABLED
|
||||||
|
|
||||||
def should_use_proot(self, job):
|
|
||||||
"""
|
|
||||||
Return whether this task should use proot.
|
|
||||||
"""
|
|
||||||
if job.is_container_group_task:
|
|
||||||
return False
|
|
||||||
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
|
||||||
|
|
||||||
def build_execution_environment_params(self, instance):
|
def build_execution_environment_params(self, instance):
|
||||||
if settings.IS_K8S:
|
if settings.IS_K8S:
|
||||||
return {}
|
return {}
|
||||||
@@ -1929,10 +1915,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
event_model = ProjectUpdateEvent
|
event_model = ProjectUpdateEvent
|
||||||
event_data_key = 'project_update_id'
|
event_data_key = 'project_update_id'
|
||||||
|
|
||||||
@property
|
|
||||||
def proot_show_paths(self):
|
|
||||||
return [settings.PROJECTS_ROOT]
|
|
||||||
|
|
||||||
def __init__(self, *args, job_private_data_dir=None, **kwargs):
|
def __init__(self, *args, job_private_data_dir=None, **kwargs):
|
||||||
super(RunProjectUpdate, self).__init__(*args, **kwargs)
|
super(RunProjectUpdate, self).__init__(*args, **kwargs)
|
||||||
self.playbook_new_revision = None
|
self.playbook_new_revision = None
|
||||||
@@ -1990,7 +1972,7 @@ class RunProjectUpdate(BaseTask):
|
|||||||
env['DISPLAY'] = '' # Prevent stupid password popup when running tests.
|
env['DISPLAY'] = '' # Prevent stupid password popup when running tests.
|
||||||
# give ansible a hint about the intended tmpdir to work around issues
|
# give ansible a hint about the intended tmpdir to work around issues
|
||||||
# like https://github.com/ansible/ansible/issues/30064
|
# like https://github.com/ansible/ansible/issues/30064
|
||||||
env['TMP'] = settings.AWX_PROOT_BASE_PATH
|
env['TMP'] = settings.AWX_ISOLATION_BASE_PATH
|
||||||
env['PROJECT_UPDATE_ID'] = str(project_update.pk)
|
env['PROJECT_UPDATE_ID'] = str(project_update.pk)
|
||||||
if settings.GALAXY_IGNORE_CERTS:
|
if settings.GALAXY_IGNORE_CERTS:
|
||||||
env['ANSIBLE_GALAXY_IGNORE'] = True
|
env['ANSIBLE_GALAXY_IGNORE'] = True
|
||||||
@@ -2124,7 +2106,7 @@ class RunProjectUpdate(BaseTask):
|
|||||||
d = super(RunProjectUpdate, self).get_password_prompts(passwords)
|
d = super(RunProjectUpdate, self).get_password_prompts(passwords)
|
||||||
d[r'Username for.*:\s*?$'] = 'scm_username'
|
d[r'Username for.*:\s*?$'] = 'scm_username'
|
||||||
d[r'Password for.*:\s*?$'] = 'scm_password'
|
d[r'Password for.*:\s*?$'] = 'scm_password'
|
||||||
d['Password:\s*?$'] = 'scm_password' # noqa
|
d[r'Password:\s*?$'] = 'scm_password'
|
||||||
d[r'\S+?@\S+?\'s\s+?password:\s*?$'] = 'scm_password'
|
d[r'\S+?@\S+?\'s\s+?password:\s*?$'] = 'scm_password'
|
||||||
d[r'Enter passphrase for .*:\s*?$'] = 'scm_key_unlock'
|
d[r'Enter passphrase for .*:\s*?$'] = 'scm_key_unlock'
|
||||||
d[r'Bad passphrase, try again for .*:\s*?$'] = ''
|
d[r'Bad passphrase, try again for .*:\s*?$'] = ''
|
||||||
@@ -2394,12 +2376,6 @@ class RunProjectUpdate(BaseTask):
|
|||||||
if status == 'successful' and instance.launch_type != 'sync':
|
if status == 'successful' and instance.launch_type != 'sync':
|
||||||
self._update_dependent_inventories(instance, dependent_inventory_sources)
|
self._update_dependent_inventories(instance, dependent_inventory_sources)
|
||||||
|
|
||||||
def should_use_proot(self, project_update):
|
|
||||||
"""
|
|
||||||
Return whether this task should use proot.
|
|
||||||
"""
|
|
||||||
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
|
||||||
|
|
||||||
def build_execution_environment_params(self, instance):
|
def build_execution_environment_params(self, instance):
|
||||||
if settings.IS_K8S:
|
if settings.IS_K8S:
|
||||||
return {}
|
return {}
|
||||||
@@ -2790,7 +2766,7 @@ class RunAdHocCommand(BaseTask):
|
|||||||
env['ANSIBLE_SFTP_BATCH_MODE'] = 'False'
|
env['ANSIBLE_SFTP_BATCH_MODE'] = 'False'
|
||||||
|
|
||||||
# Create a directory for ControlPath sockets that is unique to each
|
# Create a directory for ControlPath sockets that is unique to each
|
||||||
# ad hoc command and visible inside the proot environment (when enabled).
|
# ad hoc command
|
||||||
cp_dir = os.path.join(private_data_dir, 'cp')
|
cp_dir = os.path.join(private_data_dir, 'cp')
|
||||||
if not os.path.exists(cp_dir):
|
if not os.path.exists(cp_dir):
|
||||||
os.mkdir(cp_dir, 0o700)
|
os.mkdir(cp_dir, 0o700)
|
||||||
@@ -2894,14 +2870,6 @@ class RunAdHocCommand(BaseTask):
|
|||||||
d[r'Password:\s*?$'] = 'ssh_password'
|
d[r'Password:\s*?$'] = 'ssh_password'
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def should_use_proot(self, ad_hoc_command):
|
|
||||||
"""
|
|
||||||
Return whether this task should use proot.
|
|
||||||
"""
|
|
||||||
if ad_hoc_command.is_container_group_task:
|
|
||||||
return False
|
|
||||||
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
|
||||||
|
|
||||||
def final_run_hook(self, adhoc_job, status, private_data_dir, fact_modification_times, isolated_manager_instance=None):
|
def final_run_hook(self, adhoc_job, status, private_data_dir, fact_modification_times, isolated_manager_instance=None):
|
||||||
super(RunAdHocCommand, self).final_run_hook(adhoc_job, status, private_data_dir, fact_modification_times)
|
super(RunAdHocCommand, self).final_run_hook(adhoc_job, status, private_data_dir, fact_modification_times)
|
||||||
if isolated_manager_instance:
|
if isolated_manager_instance:
|
||||||
|
|||||||
@@ -56,24 +56,28 @@ def test_metrics_counts(organization_factory, job_template_factory, workflow_job
|
|||||||
assert EXPECTED_VALUES[name] == value
|
assert EXPECTED_VALUES[name] == value
|
||||||
|
|
||||||
|
|
||||||
|
def get_metrics_view_db_only():
|
||||||
|
return reverse('api:metrics_view') + '?dbonly=1'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_metrics_permissions(get, admin, org_admin, alice, bob, organization):
|
def test_metrics_permissions(get, admin, org_admin, alice, bob, organization):
|
||||||
assert get(reverse('api:metrics_view'), user=admin).status_code == 200
|
assert get(get_metrics_view_db_only(), user=admin).status_code == 200
|
||||||
assert get(reverse('api:metrics_view'), user=org_admin).status_code == 403
|
assert get(get_metrics_view_db_only(), user=org_admin).status_code == 403
|
||||||
assert get(reverse('api:metrics_view'), user=alice).status_code == 403
|
assert get(get_metrics_view_db_only(), user=alice).status_code == 403
|
||||||
assert get(reverse('api:metrics_view'), user=bob).status_code == 403
|
assert get(get_metrics_view_db_only(), user=bob).status_code == 403
|
||||||
organization.auditor_role.members.add(bob)
|
organization.auditor_role.members.add(bob)
|
||||||
assert get(reverse('api:metrics_view'), user=bob).status_code == 403
|
assert get(get_metrics_view_db_only(), user=bob).status_code == 403
|
||||||
|
|
||||||
Role.singleton('system_auditor').members.add(bob)
|
Role.singleton('system_auditor').members.add(bob)
|
||||||
bob.is_system_auditor = True
|
bob.is_system_auditor = True
|
||||||
assert get(reverse('api:metrics_view'), user=bob).status_code == 200
|
assert get(get_metrics_view_db_only(), user=bob).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_metrics_http_methods(get, post, patch, put, options, admin):
|
def test_metrics_http_methods(get, post, patch, put, options, admin):
|
||||||
assert get(reverse('api:metrics_view'), user=admin).status_code == 200
|
assert get(get_metrics_view_db_only(), user=admin).status_code == 200
|
||||||
assert put(reverse('api:metrics_view'), user=admin).status_code == 405
|
assert put(get_metrics_view_db_only(), user=admin).status_code == 405
|
||||||
assert patch(reverse('api:metrics_view'), user=admin).status_code == 405
|
assert patch(get_metrics_view_db_only(), user=admin).status_code == 405
|
||||||
assert post(reverse('api:metrics_view'), user=admin).status_code == 405
|
assert post(get_metrics_view_db_only(), user=admin).status_code == 405
|
||||||
assert options(reverse('api:metrics_view'), user=admin).status_code == 200
|
assert options(get_metrics_view_db_only(), user=admin).status_code == 200
|
||||||
|
|||||||
@@ -33,16 +33,14 @@ def test_jobs_settings(get, put, patch, delete, admin):
|
|||||||
response = get(url, user=admin, expect=200)
|
response = get(url, user=admin, expect=200)
|
||||||
data = dict(response.data.items())
|
data = dict(response.data.items())
|
||||||
put(url, user=admin, data=data, expect=200)
|
put(url, user=admin, data=data, expect=200)
|
||||||
patch(url, user=admin, data={'AWX_PROOT_HIDE_PATHS': ['/home']}, expect=200)
|
patch(url, user=admin, data={'AWX_ISOLATION_SHOW_PATHS': ['/home']}, expect=200)
|
||||||
response = get(url, user=admin, expect=200)
|
response = get(url, user=admin, expect=200)
|
||||||
assert response.data['AWX_PROOT_HIDE_PATHS'] == ['/home']
|
assert response.data['AWX_ISOLATION_SHOW_PATHS'] == ['/home']
|
||||||
data.pop('AWX_PROOT_HIDE_PATHS')
|
data.pop('AWX_ISOLATION_SHOW_PATHS')
|
||||||
data.pop('AWX_PROOT_SHOW_PATHS')
|
|
||||||
data.pop('AWX_ANSIBLE_CALLBACK_PLUGINS')
|
data.pop('AWX_ANSIBLE_CALLBACK_PLUGINS')
|
||||||
put(url, user=admin, data=data, expect=200)
|
put(url, user=admin, data=data, expect=200)
|
||||||
response = get(url, user=admin, expect=200)
|
response = get(url, user=admin, expect=200)
|
||||||
assert response.data['AWX_PROOT_HIDE_PATHS'] == []
|
assert response.data['AWX_ISOLATION_SHOW_PATHS'] == []
|
||||||
assert response.data['AWX_PROOT_SHOW_PATHS'] == []
|
|
||||||
assert response.data['AWX_ANSIBLE_CALLBACK_PLUGINS'] == []
|
assert response.data['AWX_ANSIBLE_CALLBACK_PLUGINS'] == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ from unittest import mock
|
|||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
Job,
|
|
||||||
InstanceGroup,
|
InstanceGroup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def T(impact):
|
def T(impact):
|
||||||
j = mock.Mock(Job())
|
j = mock.Mock(spec_set=['task_impact'])
|
||||||
j.task_impact = impact
|
j.task_impact = impact
|
||||||
return j
|
return j
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest import mock
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
@@ -14,7 +13,7 @@ from awx.main.scheduler.kubernetes import PodManager
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def container_group():
|
def container_group():
|
||||||
instance_group = mock.Mock(InstanceGroup(name='container-group'))
|
instance_group = InstanceGroup(name='container-group', id=1)
|
||||||
|
|
||||||
return instance_group
|
return instance_group
|
||||||
|
|
||||||
|
|||||||
@@ -725,7 +725,6 @@ class TestIsolatedExecution(TestJobExecution):
|
|||||||
extra_vars = json.loads(extra_vars)
|
extra_vars = json.loads(extra_vars)
|
||||||
assert extra_vars['dest'] == '/tmp'
|
assert extra_vars['dest'] == '/tmp'
|
||||||
assert extra_vars['src'] == private_data
|
assert extra_vars['src'] == private_data
|
||||||
assert extra_vars['proot_temp_dir'].startswith('/tmp/awx_proot_')
|
|
||||||
|
|
||||||
def test_systemctl_failure(self):
|
def test_systemctl_failure(self):
|
||||||
# If systemctl fails, read the contents of `artifacts/systemctl_logs`
|
# If systemctl fails, read the contents of `artifacts/systemctl_logs`
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ __all__ = [
|
|||||||
'underscore_to_camelcase',
|
'underscore_to_camelcase',
|
||||||
'memoize',
|
'memoize',
|
||||||
'memoize_delete',
|
'memoize_delete',
|
||||||
'get_ansible_version',
|
|
||||||
'get_licenser',
|
'get_licenser',
|
||||||
'get_awx_http_client_headers',
|
'get_awx_http_client_headers',
|
||||||
'get_awx_version',
|
'get_awx_version',
|
||||||
@@ -69,9 +68,6 @@ __all__ = [
|
|||||||
'get_system_task_capacity',
|
'get_system_task_capacity',
|
||||||
'get_cpu_capacity',
|
'get_cpu_capacity',
|
||||||
'get_mem_capacity',
|
'get_mem_capacity',
|
||||||
'wrap_args_with_proot',
|
|
||||||
'build_proot_temp_dir',
|
|
||||||
'check_proot_installed',
|
|
||||||
'model_to_dict',
|
'model_to_dict',
|
||||||
'NullablePromptPseudoField',
|
'NullablePromptPseudoField',
|
||||||
'model_instance_diff',
|
'model_instance_diff',
|
||||||
@@ -195,20 +191,6 @@ def memoize_delete(function_name):
|
|||||||
return cache.delete(function_name)
|
return cache.delete(function_name)
|
||||||
|
|
||||||
|
|
||||||
@memoize()
|
|
||||||
def get_ansible_version():
|
|
||||||
"""
|
|
||||||
Return Ansible version installed.
|
|
||||||
Ansible path needs to be provided to account for custom virtual environments
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(['ansible', '--version'], stdout=subprocess.PIPE)
|
|
||||||
result = smart_str(proc.communicate()[0])
|
|
||||||
return result.split('\n')[0].replace('ansible', '').strip()
|
|
||||||
except Exception:
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
|
|
||||||
def get_awx_version():
|
def get_awx_version():
|
||||||
"""
|
"""
|
||||||
Return AWX version as reported by setuptools.
|
Return AWX version as reported by setuptools.
|
||||||
@@ -842,94 +824,6 @@ def set_environ(**environ):
|
|||||||
os.environ.update(old_environ)
|
os.environ.update(old_environ)
|
||||||
|
|
||||||
|
|
||||||
@memoize()
|
|
||||||
def check_proot_installed():
|
|
||||||
"""
|
|
||||||
Check that proot is installed.
|
|
||||||
"""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
cmd = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--version']
|
|
||||||
try:
|
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
proc.communicate()
|
|
||||||
return bool(proc.returncode == 0)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
if isinstance(e, ValueError) or getattr(e, 'errno', 1) != 2: # ENOENT, no such file or directory
|
|
||||||
logger.exception('bwrap unavailable for unexpected reason.')
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def build_proot_temp_dir():
|
|
||||||
"""
|
|
||||||
Create a temporary directory for proot to use.
|
|
||||||
"""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
path = tempfile.mkdtemp(prefix='awx_proot_', dir=settings.AWX_PROOT_BASE_PATH)
|
|
||||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_args_with_proot(args, cwd, **kwargs):
|
|
||||||
"""
|
|
||||||
Wrap existing command line with proot to restrict access to:
|
|
||||||
- AWX_PROOT_BASE_PATH (generally, /tmp) (except for own /tmp files)
|
|
||||||
For non-isolated nodes:
|
|
||||||
- /etc/tower (to prevent obtaining db info or secret key)
|
|
||||||
- /var/lib/awx (except for current project)
|
|
||||||
- /var/log/tower
|
|
||||||
- /var/log/supervisor
|
|
||||||
"""
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
cwd = os.path.realpath(cwd)
|
|
||||||
new_args = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--unshare-pid', '--dev-bind', '/', '/', '--proc', '/proc']
|
|
||||||
hide_paths = [settings.AWX_PROOT_BASE_PATH]
|
|
||||||
if not kwargs.get('isolated'):
|
|
||||||
hide_paths.extend(['/etc/tower', '/var/lib/awx', '/var/log', '/etc/ssh', settings.PROJECTS_ROOT, settings.JOBOUTPUT_ROOT])
|
|
||||||
hide_paths.extend(getattr(settings, 'AWX_PROOT_HIDE_PATHS', None) or [])
|
|
||||||
for path in sorted(set(hide_paths)):
|
|
||||||
if not os.path.exists(path):
|
|
||||||
continue
|
|
||||||
path = os.path.realpath(path)
|
|
||||||
if os.path.isdir(path):
|
|
||||||
new_path = tempfile.mkdtemp(dir=kwargs['proot_temp_dir'])
|
|
||||||
os.chmod(new_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
|
||||||
else:
|
|
||||||
handle, new_path = tempfile.mkstemp(dir=kwargs['proot_temp_dir'])
|
|
||||||
os.close(handle)
|
|
||||||
os.chmod(new_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
||||||
new_args.extend(['--bind', '%s' % (new_path,), '%s' % (path,)])
|
|
||||||
if kwargs.get('isolated'):
|
|
||||||
show_paths = [kwargs['private_data_dir']]
|
|
||||||
elif 'private_data_dir' in kwargs:
|
|
||||||
show_paths = [cwd, kwargs['private_data_dir']]
|
|
||||||
else:
|
|
||||||
show_paths = [cwd]
|
|
||||||
for venv in (settings.ANSIBLE_VENV_PATH, settings.AWX_VENV_PATH, kwargs.get('proot_custom_virtualenv')):
|
|
||||||
if venv:
|
|
||||||
new_args.extend(['--ro-bind', venv, venv])
|
|
||||||
show_paths.extend(getattr(settings, 'AWX_PROOT_SHOW_PATHS', None) or [])
|
|
||||||
show_paths.extend(kwargs.get('proot_show_paths', []))
|
|
||||||
for path in sorted(set(show_paths)):
|
|
||||||
if not os.path.exists(path):
|
|
||||||
continue
|
|
||||||
path = os.path.realpath(path)
|
|
||||||
new_args.extend(['--bind', '%s' % (path,), '%s' % (path,)])
|
|
||||||
if kwargs.get('isolated'):
|
|
||||||
if '/bin/ansible-playbook' in ' '.join(args):
|
|
||||||
# playbook runs should cwd to the SCM checkout dir
|
|
||||||
new_args.extend(['--chdir', os.path.join(kwargs['private_data_dir'], 'project')])
|
|
||||||
else:
|
|
||||||
# ad-hoc runs should cwd to the root of the private data dir
|
|
||||||
new_args.extend(['--chdir', kwargs['private_data_dir']])
|
|
||||||
else:
|
|
||||||
new_args.extend(['--chdir', cwd])
|
|
||||||
new_args.extend(args)
|
|
||||||
return new_args
|
|
||||||
|
|
||||||
|
|
||||||
def get_pk_from_dict(_dict, key):
|
def get_pk_from_dict(_dict, key):
|
||||||
"""
|
"""
|
||||||
Helper for obtaining a pk from user data dict or None if not present.
|
Helper for obtaining a pk from user data dict or None if not present.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -34,7 +35,8 @@ class RSysLogHandler(logging.handlers.SysLogHandler):
|
|||||||
# because the alternative is blocking the
|
# because the alternative is blocking the
|
||||||
# socket.send() in the Python process, which we definitely don't
|
# socket.send() in the Python process, which we definitely don't
|
||||||
# want to do)
|
# want to do)
|
||||||
msg = f'{record.asctime} ERROR rsyslogd was unresponsive: '
|
dt = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
msg = f'{dt} ERROR rsyslogd was unresponsive: '
|
||||||
exc = traceback.format_exc()
|
exc = traceback.format_exc()
|
||||||
try:
|
try:
|
||||||
msg += exc.splitlines()[-1]
|
msg += exc.splitlines()[-1]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from awx.main.analytics.broadcast_websocket import (
|
|||||||
BroadcastWebsocketStats,
|
BroadcastWebsocketStats,
|
||||||
BroadcastWebsocketStatsManager,
|
BroadcastWebsocketStatsManager,
|
||||||
)
|
)
|
||||||
|
import awx.main.analytics.subsystem_metrics as s_metrics
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.wsbroadcast')
|
logger = logging.getLogger('awx.main.wsbroadcast')
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ class WebsocketTask:
|
|||||||
self.protocol = protocol
|
self.protocol = protocol
|
||||||
self.verify_ssl = verify_ssl
|
self.verify_ssl = verify_ssl
|
||||||
self.channel_layer = None
|
self.channel_layer = None
|
||||||
|
self.subsystem_metrics = s_metrics.Metrics()
|
||||||
|
|
||||||
async def run_loop(self, websocket: aiohttp.ClientWebSocketResponse):
|
async def run_loop(self, websocket: aiohttp.ClientWebSocketResponse):
|
||||||
raise RuntimeError("Implement me")
|
raise RuntimeError("Implement me")
|
||||||
@@ -144,9 +145,10 @@ class BroadcastWebsocketTask(WebsocketTask):
|
|||||||
logmsg = "{} {}".format(logmsg, payload)
|
logmsg = "{} {}".format(logmsg, payload)
|
||||||
logger.warn(logmsg)
|
logger.warn(logmsg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
(group, message) = unwrap_broadcast_msg(payload)
|
(group, message) = unwrap_broadcast_msg(payload)
|
||||||
|
if group == "metrics":
|
||||||
|
self.subsystem_metrics.store_metrics(message)
|
||||||
|
continue
|
||||||
await self.channel_layer.group_send(group, {"type": "internal.message", "text": message})
|
await self.channel_layer.group_send(group, {"type": "internal.message", "text": message})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,15 @@ JOB_EVENT_MAX_QUEUE_SIZE = 10000
|
|||||||
# The number of job events to migrate per-transaction when moving from int -> bigint
|
# The number of job events to migrate per-transaction when moving from int -> bigint
|
||||||
JOB_EVENT_MIGRATION_CHUNK_SIZE = 1000000
|
JOB_EVENT_MIGRATION_CHUNK_SIZE = 1000000
|
||||||
|
|
||||||
|
# Histogram buckets for the callback_receiver_batch_events_insert_db metric
|
||||||
|
SUBSYSTEM_METRICS_BATCH_INSERT_BUCKETS = [10, 50, 150, 350, 650, 2000]
|
||||||
|
|
||||||
|
# Interval in seconds for sending local metrics to other nodes
|
||||||
|
SUBSYSTEM_METRICS_INTERVAL_SEND_METRICS = 3
|
||||||
|
|
||||||
|
# Interval in seconds for saving local metrics to redis
|
||||||
|
SUBSYSTEM_METRICS_INTERVAL_SAVE_TO_REDIS = 2
|
||||||
|
|
||||||
# The maximum allowed jobs to start on a given task manager cycle
|
# The maximum allowed jobs to start on a given task manager cycle
|
||||||
START_TASK_LIMIT = 100
|
START_TASK_LIMIT = 100
|
||||||
|
|
||||||
@@ -427,6 +436,7 @@ CELERYBEAT_SCHEDULE = {
|
|||||||
'gather_analytics': {'task': 'awx.main.tasks.gather_analytics', 'schedule': timedelta(minutes=5)},
|
'gather_analytics': {'task': 'awx.main.tasks.gather_analytics', 'schedule': timedelta(minutes=5)},
|
||||||
'task_manager': {'task': 'awx.main.scheduler.tasks.run_task_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}},
|
'task_manager': {'task': 'awx.main.scheduler.tasks.run_task_manager', 'schedule': timedelta(seconds=20), 'options': {'expires': 20}},
|
||||||
'k8s_reaper': {'task': 'awx.main.tasks.awx_k8s_reaper', 'schedule': timedelta(seconds=60), 'options': {'expires': 50}},
|
'k8s_reaper': {'task': 'awx.main.tasks.awx_k8s_reaper', 'schedule': timedelta(seconds=60), 'options': {'expires': 50}},
|
||||||
|
'send_subsystem_metrics': {'task': 'awx.main.analytics.analytics_tasks.send_subsystem_metrics', 'schedule': timedelta(seconds=20)},
|
||||||
# 'isolated_heartbeat': set up at the end of production.py and development.py
|
# 'isolated_heartbeat': set up at the end of production.py and development.py
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,26 +579,15 @@ AWX_SHOW_PLAYBOOK_LINKS = False
|
|||||||
# Applies to any galaxy server
|
# Applies to any galaxy server
|
||||||
GALAXY_IGNORE_CERTS = False
|
GALAXY_IGNORE_CERTS = False
|
||||||
|
|
||||||
# Enable bubblewrap support for running jobs (playbook runs only).
|
# Additional paths to show for jobs using process isolation.
|
||||||
# Note: This setting may be overridden by database settings.
|
# Note: This setting may be overridden by database settings.
|
||||||
AWX_PROOT_ENABLED = True
|
AWX_ISOLATION_SHOW_PATHS = []
|
||||||
|
|
||||||
# Command/path to bubblewrap.
|
|
||||||
AWX_PROOT_CMD = 'bwrap'
|
|
||||||
|
|
||||||
# Additional paths to hide from jobs using bubblewrap.
|
|
||||||
# Note: This setting may be overridden by database settings.
|
|
||||||
AWX_PROOT_HIDE_PATHS = []
|
|
||||||
|
|
||||||
# Additional paths to show for jobs using bubbelwrap.
|
|
||||||
# Note: This setting may be overridden by database settings.
|
|
||||||
AWX_PROOT_SHOW_PATHS = []
|
|
||||||
|
|
||||||
# The directory in which Tower will create new temporary directories for job
|
# The directory in which Tower will create new temporary directories for job
|
||||||
# execution and isolation (such as credential files and custom
|
# execution and isolation (such as credential files and custom
|
||||||
# inventory scripts).
|
# inventory scripts).
|
||||||
# Note: This setting may be overridden by database settings.
|
# Note: This setting may be overridden by database settings.
|
||||||
AWX_PROOT_BASE_PATH = "/tmp"
|
AWX_ISOLATION_BASE_PATH = "/tmp"
|
||||||
|
|
||||||
# Disable resource profiling by default
|
# Disable resource profiling by default
|
||||||
AWX_RESOURCE_PROFILING_ENABLED = False
|
AWX_RESOURCE_PROFILING_ENABLED = False
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ CALLBACK_QUEUE = "callback_tasks"
|
|||||||
# Note: This setting may be overridden by database settings.
|
# Note: This setting may be overridden by database settings.
|
||||||
AWX_ROLES_ENABLED = True
|
AWX_ROLES_ENABLED = True
|
||||||
|
|
||||||
# Enable PROOT for tower-qa integration tests.
|
|
||||||
# Note: This setting may be overridden by database settings.
|
|
||||||
AWX_PROOT_ENABLED = True
|
|
||||||
|
|
||||||
AWX_ISOLATED_USERNAME = 'root'
|
AWX_ISOLATED_USERNAME = 'root'
|
||||||
AWX_ISOLATED_CHECK_INTERVAL = 1
|
AWX_ISOLATED_CHECK_INTERVAL = 1
|
||||||
AWX_ISOLATED_PERIODIC_CHECK = 30
|
AWX_ISOLATED_PERIODIC_CHECK = 30
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
const RelaunchMixin = parent =>
|
|
||||||
class extends parent {
|
|
||||||
relaunch(id, data) {
|
|
||||||
return this.http.post(`${this.baseUrl}${id}/relaunch/`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
readRelaunch(id) {
|
|
||||||
return this.http.get(`${this.baseUrl}${id}/relaunch/`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RelaunchMixin;
|
|
||||||
48
awx/ui_next/src/api/mixins/Runnable.mixin.js
Normal file
48
awx/ui_next/src/api/mixins/Runnable.mixin.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const Runnable = parent =>
|
||||||
|
class extends parent {
|
||||||
|
jobEventSlug = '/events/';
|
||||||
|
|
||||||
|
cancel(id) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}/cancel/`;
|
||||||
|
|
||||||
|
return this.http.post(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
launchUpdate(id, data) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}/update/`;
|
||||||
|
|
||||||
|
return this.http.post(endpoint, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
readLaunchUpdate(id) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}/update/`;
|
||||||
|
|
||||||
|
return this.http.get(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
readEvents(id, params = {}) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`;
|
||||||
|
|
||||||
|
return this.http.get(endpoint, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
readEventOptions(id) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}${this.jobEventSlug}`;
|
||||||
|
|
||||||
|
return this.http.options(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
readRelaunch(id) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}/relaunch/`;
|
||||||
|
|
||||||
|
return this.http.get(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
relaunch(id, data) {
|
||||||
|
const endpoint = `${this.baseUrl}${id}/relaunch/`;
|
||||||
|
|
||||||
|
return this.http.post(endpoint, data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Runnable;
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import RelaunchMixin from '../mixins/Relaunch.mixin';
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
class AdHocCommands extends RelaunchMixin(Base) {
|
class AdHocCommands extends RunnableMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/ad_hoc_commands/';
|
this.baseUrl = '/api/v2/ad_hoc_commands/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AdHocCommands;
|
export default AdHocCommands;
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ class Inventories extends InstanceGroupsMixin(Base) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSources(inventoryId) {
|
||||||
|
return this.http.get(
|
||||||
|
`${this.baseUrl}${inventoryId}/update_inventory_sources/`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async readSourceDetail(inventoryId, sourceId) {
|
async readSourceDetail(inventoryId, sourceId) {
|
||||||
const {
|
const {
|
||||||
data: { results },
|
data: { results },
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ class InventorySources extends LaunchUpdateMixin(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readGroups(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/groups/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
readHosts(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/hosts/`);
|
||||||
|
}
|
||||||
|
|
||||||
destroyGroups(id) {
|
destroyGroups(id) {
|
||||||
return this.http.delete(`${this.baseUrl}${id}/groups/`);
|
return this.http.delete(`${this.baseUrl}${id}/groups/`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import LaunchUpdateMixin from '../mixins/LaunchUpdate.mixin';
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
class InventoryUpdates extends LaunchUpdateMixin(Base) {
|
class InventoryUpdates extends RunnableMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/inventory_updates/';
|
this.baseUrl = '/api/v2/inventory_updates/';
|
||||||
@@ -11,5 +11,9 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) {
|
|||||||
createSyncCancel(sourceId) {
|
createSyncCancel(sourceId) {
|
||||||
return this.http.post(`${this.baseUrl}${sourceId}/cancel/`);
|
return this.http.post(`${this.baseUrl}${sourceId}/cancel/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default InventoryUpdates;
|
export default InventoryUpdates;
|
||||||
|
|||||||
@@ -1,67 +1,23 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import RelaunchMixin from '../mixins/Relaunch.mixin';
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
const getBaseURL = type => {
|
class Jobs extends RunnableMixin(Base) {
|
||||||
switch (type) {
|
|
||||||
case 'playbook':
|
|
||||||
case 'job':
|
|
||||||
return '/jobs/';
|
|
||||||
case 'project':
|
|
||||||
case 'project_update':
|
|
||||||
return '/project_updates/';
|
|
||||||
case 'management':
|
|
||||||
case 'management_job':
|
|
||||||
return '/system_jobs/';
|
|
||||||
case 'inventory':
|
|
||||||
case 'inventory_update':
|
|
||||||
return '/inventory_updates/';
|
|
||||||
case 'command':
|
|
||||||
case 'ad_hoc_command':
|
|
||||||
return '/ad_hoc_commands/';
|
|
||||||
case 'workflow':
|
|
||||||
case 'workflow_job':
|
|
||||||
return '/workflow_jobs/';
|
|
||||||
default:
|
|
||||||
throw new Error('Unable to find matching job type');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class Jobs extends RelaunchMixin(Base) {
|
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/jobs/';
|
this.baseUrl = '/api/v2/jobs/';
|
||||||
|
this.jobEventSlug = '/job_events/';
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(id, type) {
|
cancel(id) {
|
||||||
return this.http.post(`/api/v2${getBaseURL(type)}${id}/cancel/`);
|
return this.http.post(`${this.baseUrl}${id}/cancel/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readCredentials(id, type) {
|
readCredentials(id) {
|
||||||
return this.http.get(`/api/v2${getBaseURL(type)}${id}/credentials/`);
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
readDetail(id, type) {
|
readDetail(id) {
|
||||||
return this.http.get(`/api/v2${getBaseURL(type)}${id}/`);
|
return this.http.get(`${this.baseUrl}${id}/`);
|
||||||
}
|
|
||||||
|
|
||||||
readEvents(id, type = 'playbook', params = {}) {
|
|
||||||
let endpoint;
|
|
||||||
if (type === 'playbook') {
|
|
||||||
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
|
|
||||||
} else {
|
|
||||||
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
|
|
||||||
}
|
|
||||||
return this.http.get(endpoint, { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
readEventOptions(id, type = 'playbook') {
|
|
||||||
let endpoint;
|
|
||||||
if (type === 'playbook') {
|
|
||||||
endpoint = `/api/v2${getBaseURL(type)}${id}/job_events/`;
|
|
||||||
} else {
|
|
||||||
endpoint = `/api/v2${getBaseURL(type)}${id}/events/`;
|
|
||||||
}
|
|
||||||
return this.http.options(endpoint);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
awx/ui_next/src/api/models/Metrics.js
Normal file
9
awx/ui_next/src/api/models/Metrics.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Base from '../Base';
|
||||||
|
|
||||||
|
class Metrics extends Base {
|
||||||
|
constructor(http) {
|
||||||
|
super(http);
|
||||||
|
this.baseUrl = '/api/v2/inventories/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default Metrics;
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
class ProjectUpdates extends Base {
|
class ProjectUpdates extends RunnableMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/project_updates/';
|
this.baseUrl = '/api/v2/project_updates/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProjectUpdates;
|
export default ProjectUpdates;
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
|
|
||||||
class SystemJobs extends Base {
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
|
class SystemJobs extends RunnableMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/system_jobs/';
|
this.baseUrl = '/api/v2/system_jobs/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SystemJobs;
|
export default SystemJobs;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Base from '../Base';
|
import Base from '../Base';
|
||||||
import RelaunchMixin from '../mixins/Relaunch.mixin';
|
import RunnableMixin from '../mixins/Runnable.mixin';
|
||||||
|
|
||||||
class WorkflowJobs extends RelaunchMixin(Base) {
|
class WorkflowJobs extends RunnableMixin(Base) {
|
||||||
constructor(http) {
|
constructor(http) {
|
||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/workflow_jobs/';
|
this.baseUrl = '/api/v2/workflow_jobs/';
|
||||||
@@ -10,6 +10,10 @@ class WorkflowJobs extends RelaunchMixin(Base) {
|
|||||||
readNodes(id, params) {
|
readNodes(id, params) {
|
||||||
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
|
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readCredentials(id) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/credentials/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WorkflowJobs;
|
export default WorkflowJobs;
|
||||||
|
|||||||
@@ -2,17 +2,12 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import {
|
import { AboutModal } from '@patternfly/react-core';
|
||||||
AboutModal,
|
|
||||||
TextContent,
|
|
||||||
TextList,
|
|
||||||
TextListItem,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
|
|
||||||
import { BrandName } from '../../variables';
|
import { BrandName } from '../../variables';
|
||||||
import brandLogoImg from './brand-logo.svg';
|
import brandLogoImg from './brand-logo.svg';
|
||||||
|
|
||||||
function About({ ansible_version, version, isOpen, onClose, i18n }) {
|
function About({ version, isOpen, onClose, i18n }) {
|
||||||
const createSpeechBubble = () => {
|
const createSpeechBubble = () => {
|
||||||
let text = `${BrandName} ${version}`;
|
let text = `${BrandName} ${version}`;
|
||||||
let top = '';
|
let top = '';
|
||||||
@@ -52,27 +47,17 @@ function About({ ansible_version, version, isOpen, onClose, i18n }) {
|
|||||||
|| ||
|
|| ||
|
||||||
`}
|
`}
|
||||||
</pre>
|
</pre>
|
||||||
<TextContent>
|
|
||||||
<TextList component="dl">
|
|
||||||
<TextListItem component="dt">
|
|
||||||
{i18n._(t`Ansible Version`)}
|
|
||||||
</TextListItem>
|
|
||||||
<TextListItem component="dd">{ansible_version}</TextListItem>
|
|
||||||
</TextList>
|
|
||||||
</TextContent>
|
|
||||||
</AboutModal>
|
</AboutModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
About.propTypes = {
|
About.propTypes = {
|
||||||
ansible_version: PropTypes.string,
|
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
version: PropTypes.string,
|
version: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
About.defaultProps = {
|
About.defaultProps = {
|
||||||
ansible_version: null,
|
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
version: null,
|
version: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ function AddResourceRole({ onSave, onClose, roles, i18n, resource, onError }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (selectedIndex > -1) {
|
if (selectedIndex > -1) {
|
||||||
selectedRoleRows.splice(selectedIndex, 1);
|
setSelectedRoleRows(
|
||||||
setSelectedRoleRows(selectedRoleRows);
|
selectedRoleRows.filter((r, index) => index !== selectedIndex)
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRoleRows([...selectedRoleRows, role]);
|
setSelectedRoleRows([...selectedRoleRows, role]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ function AppContainer({ i18n, navRouteConfig = [], children }) {
|
|||||||
{isReady && <ConfigProvider value={config}>{children}</ConfigProvider>}
|
{isReady && <ConfigProvider value={config}>{children}</ConfigProvider>}
|
||||||
</Page>
|
</Page>
|
||||||
<About
|
<About
|
||||||
ansible_version={config?.ansible_version}
|
|
||||||
version={config?.version}
|
version={config?.version}
|
||||||
isOpen={isAboutModalOpen}
|
isOpen={isAboutModalOpen}
|
||||||
onClose={handleAboutModalClose}
|
onClose={handleAboutModalClose}
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ import AppContainer from './AppContainer';
|
|||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
|
|
||||||
describe('<AppContainer />', () => {
|
describe('<AppContainer />', () => {
|
||||||
const ansible_version = '111';
|
|
||||||
const version = '222';
|
const version = '222';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ConfigAPI.read.mockResolvedValue({
|
ConfigAPI.read.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
ansible_version,
|
|
||||||
version,
|
version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -93,7 +91,6 @@ describe('<AppContainer />', () => {
|
|||||||
|
|
||||||
// check about modal content
|
// check about modal content
|
||||||
const content = await waitForElement(wrapper, aboutModalContent);
|
const content = await waitForElement(wrapper, aboutModalContent);
|
||||||
expect(content.find('dd').text()).toContain(ansible_version);
|
|
||||||
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
|
expect(content.find('pre').text()).toContain(`< AWX ${version} >`);
|
||||||
|
|
||||||
// close about modal
|
// close about modal
|
||||||
|
|||||||
@@ -2,9 +2,20 @@ import React, { useState } from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Button } from '@patternfly/react-core';
|
import styled from 'styled-components';
|
||||||
|
import { Button, Badge, Alert, Tooltip } from '@patternfly/react-core';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
|
import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails';
|
||||||
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
|
const WarningMessage = styled(Alert)`
|
||||||
|
margin-top: 10px;
|
||||||
|
`;
|
||||||
|
const Label = styled.span`
|
||||||
|
&& {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
function DeleteButton({
|
function DeleteButton({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
modalTitle,
|
modalTitle,
|
||||||
@@ -14,33 +25,91 @@ function DeleteButton({
|
|||||||
children,
|
children,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
ouiaId,
|
ouiaId,
|
||||||
|
deleteMessage,
|
||||||
|
deleteDetailsRequests,
|
||||||
|
disabledTooltip,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [deleteMessageError, setDeleteMessageError] = useState();
|
||||||
|
const [deleteDetails, setDeleteDetails] = useState({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const toggleModal = async isModalOpen => {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (deleteDetailsRequests?.length && isModalOpen) {
|
||||||
|
const { results, error } = await getRelatedResourceDeleteCounts(
|
||||||
|
deleteDetailsRequests
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
setDeleteMessageError(error);
|
||||||
|
} else {
|
||||||
|
setDeleteDetails(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsOpen(isModalOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deleteMessageError) {
|
||||||
|
return (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deleteMessageError}
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={() => {
|
||||||
|
toggleModal(false);
|
||||||
|
setDeleteMessageError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={deleteMessageError} />
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
{disabledTooltip ? (
|
||||||
variant={variant || 'secondary'}
|
<Tooltip content={disabledTooltip} position="top">
|
||||||
aria-label={i18n._(t`Delete`)}
|
<div>
|
||||||
isDisabled={isDisabled}
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
isLoading={isLoading}
|
||||||
ouiaId={ouiaId}
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
>
|
variant={variant || 'secondary'}
|
||||||
{children || i18n._(t`Delete`)}
|
aria-label={i18n._(t`Delete`)}
|
||||||
</Button>
|
isDisabled={isDisabled}
|
||||||
|
onClick={() => toggleModal(true)}
|
||||||
|
ouiaId={ouiaId}
|
||||||
|
>
|
||||||
|
{children || i18n._(t`Delete`)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
|
variant={variant || 'secondary'}
|
||||||
|
aria-label={i18n._(t`Delete`)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onClick={() => toggleModal(true)}
|
||||||
|
>
|
||||||
|
{children || i18n._(t`Delete`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
title={modalTitle}
|
title={modalTitle}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClose={() => setIsOpen(false)}
|
onClose={() => toggleModal(false)}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button
|
||||||
ouiaId="delete-modal-confirm"
|
ouiaId="delete-modal-confirm"
|
||||||
key="delete"
|
key="delete"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
aria-label={i18n._(t`Delete`)}
|
aria-label={i18n._(t`Confirm Delete`)}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
onClick={onConfirm}
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
toggleModal(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -49,7 +118,7 @@ function DeleteButton({
|
|||||||
key="cancel"
|
key="cancel"
|
||||||
variant="link"
|
variant="link"
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => toggleModal(false)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -58,6 +127,23 @@ function DeleteButton({
|
|||||||
{i18n._(t`Are you sure you want to delete:`)}
|
{i18n._(t`Are you sure you want to delete:`)}
|
||||||
<br />
|
<br />
|
||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
|
{Object.values(deleteDetails).length > 0 && (
|
||||||
|
<WarningMessage
|
||||||
|
variant="warning"
|
||||||
|
isInline
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<div aria-label={deleteMessage}>{deleteMessage}</div>
|
||||||
|
<br />
|
||||||
|
{Object.entries(deleteDetails).map(([key, value]) => (
|
||||||
|
<div aria-label={`${key}: ${value}`} key={key}>
|
||||||
|
<Label>{key}</Label> <Badge>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
112
awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx
Normal file
112
awx/ui_next/src/components/DeleteButton/DeleteButton.test.jsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
|
import { CredentialsAPI } from '../../api';
|
||||||
|
import DeleteButton from './DeleteButton';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
|
describe('<DeleteButton />', () => {
|
||||||
|
test('should render button', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<DeleteButton onConfirm={() => {}} name="Foo" />
|
||||||
|
);
|
||||||
|
expect(wrapper.find('button')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open confirmation modal', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<DeleteButton
|
||||||
|
onConfirm={() => {}}
|
||||||
|
name="Foo"
|
||||||
|
deleteDetailsRequests={[
|
||||||
|
{
|
||||||
|
label: 'job',
|
||||||
|
request: CredentialsAPI.read.mockResolvedValue({
|
||||||
|
data: { count: 1 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
warningMessage="Are you sure to want to delete this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button').prop('onClick')();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForElement(wrapper, 'Modal', el => el.length > 0);
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should invoke onConfirm prop', async () => {
|
||||||
|
const onConfirm = jest.fn();
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<DeleteButton
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
itemsToDelete="foo"
|
||||||
|
deleteDetailsRequests={[
|
||||||
|
{
|
||||||
|
label: 'job',
|
||||||
|
request: CredentialsAPI.read.mockResolvedValue({
|
||||||
|
data: { count: 1 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await act(async () => wrapper.find('button').simulate('click'));
|
||||||
|
wrapper.update();
|
||||||
|
await act(async () =>
|
||||||
|
wrapper
|
||||||
|
.find('ModalBoxFooter button[aria-label="Confirm Delete"]')
|
||||||
|
.simulate('click')
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(onConfirm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show delete details error', async () => {
|
||||||
|
const onConfirm = jest.fn();
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<DeleteButton
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
itemsToDelete="foo"
|
||||||
|
deleteDetailsRequests={[
|
||||||
|
{
|
||||||
|
label: 'job',
|
||||||
|
request: CredentialsAPI.read.mockRejectedValue(
|
||||||
|
new Error({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v2/credentals',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await act(async () => wrapper.find('button').simulate('click'));
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,20 +13,12 @@ import useRequest, {
|
|||||||
useDeleteItems,
|
useDeleteItems,
|
||||||
useDismissableError,
|
useDismissableError,
|
||||||
} from '../../util/useRequest';
|
} from '../../util/useRequest';
|
||||||
import isJobRunning from '../../util/jobs';
|
import { isJobRunning, getJobModel } from '../../util/jobs';
|
||||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||||
import JobListItem from './JobListItem';
|
import JobListItem from './JobListItem';
|
||||||
import JobListCancelButton from './JobListCancelButton';
|
import JobListCancelButton from './JobListCancelButton';
|
||||||
import useWsJobs from './useWsJobs';
|
import useWsJobs from './useWsJobs';
|
||||||
import {
|
import { UnifiedJobsAPI } from '../../api';
|
||||||
AdHocCommandsAPI,
|
|
||||||
InventoryUpdatesAPI,
|
|
||||||
JobsAPI,
|
|
||||||
ProjectUpdatesAPI,
|
|
||||||
SystemJobsAPI,
|
|
||||||
UnifiedJobsAPI,
|
|
||||||
WorkflowJobsAPI,
|
|
||||||
} from '../../api';
|
|
||||||
|
|
||||||
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||||
const qsConfig = getQSConfig(
|
const qsConfig = getQSConfig(
|
||||||
@@ -104,7 +96,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
return Promise.all(
|
return Promise.all(
|
||||||
selected.map(job => {
|
selected.map(job => {
|
||||||
if (isJobRunning(job.status)) {
|
if (isJobRunning(job.status)) {
|
||||||
return JobsAPI.cancel(job.id, job.type);
|
return getJobModel(job.type).cancel(job.id);
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
})
|
})
|
||||||
@@ -127,22 +119,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
selected.map(({ type, id }) => {
|
selected.map(({ type, id }) => {
|
||||||
switch (type) {
|
return getJobModel(type).destroy(id);
|
||||||
case 'job':
|
|
||||||
return JobsAPI.destroy(id);
|
|
||||||
case 'ad_hoc_command':
|
|
||||||
return AdHocCommandsAPI.destroy(id);
|
|
||||||
case 'system_job':
|
|
||||||
return SystemJobsAPI.destroy(id);
|
|
||||||
case 'project_update':
|
|
||||||
return ProjectUpdatesAPI.destroy(id);
|
|
||||||
case 'inventory_update':
|
|
||||||
return InventoryUpdatesAPI.destroy(id);
|
|
||||||
case 'workflow_job':
|
|
||||||
return WorkflowJobsAPI.destroy(id);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [selected]),
|
}, [selected]),
|
||||||
|
|||||||
@@ -319,13 +319,12 @@ describe('<JobList />', () => {
|
|||||||
wrapper.find('JobListCancelButton').invoke('onCancel')();
|
wrapper.find('JobListCancelButton').invoke('onCancel')();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledTimes(6);
|
expect(ProjectUpdatesAPI.cancel).toHaveBeenCalledWith(1);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(1, 'project_update');
|
expect(JobsAPI.cancel).toHaveBeenCalledWith(2);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(2, 'job');
|
expect(InventoryUpdatesAPI.cancel).toHaveBeenCalledWith(3);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(3, 'inventory_update');
|
expect(WorkflowJobsAPI.cancel).toHaveBeenCalledWith(4);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(4, 'workflow_job');
|
expect(SystemJobsAPI.cancel).toHaveBeenCalledWith(5);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(5, 'system_job');
|
expect(AdHocCommandsAPI.cancel).toHaveBeenCalledWith(6);
|
||||||
expect(JobsAPI.cancel).toHaveBeenCalledWith(6, 'ad_hoc_command');
|
|
||||||
|
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { t } from '@lingui/macro';
|
|||||||
import { arrayOf, func } from 'prop-types';
|
import { arrayOf, func } from 'prop-types';
|
||||||
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
||||||
import { KebabifiedContext } from '../../contexts/Kebabified';
|
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||||
import isJobRunning from '../../util/jobs';
|
import { isJobRunning } from '../../util/jobs';
|
||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import { Job } from '../../types';
|
import { Job } from '../../types';
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function ExecutionEnvironmentLookup({
|
|||||||
globallyAvailable,
|
globallyAvailable,
|
||||||
i18n,
|
i18n,
|
||||||
isDefaultEnvironment,
|
isDefaultEnvironment,
|
||||||
|
isGlobalDefaultEnvironment,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
onBlur,
|
onBlur,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -154,17 +155,26 @@ function ExecutionEnvironmentLookup({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderLabel = (
|
||||||
|
globalDefaultEnvironment,
|
||||||
|
defaultExecutionEnvironment
|
||||||
|
) => {
|
||||||
|
if (globalDefaultEnvironment) {
|
||||||
|
return i18n._(t`Global Default Execution Environment`);
|
||||||
|
}
|
||||||
|
if (defaultExecutionEnvironment) {
|
||||||
|
return i18n._(t`Default Execution Environment`);
|
||||||
|
}
|
||||||
|
return i18n._(t`Execution Environment`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup
|
<FormGroup
|
||||||
fieldId="execution-environment-lookup"
|
fieldId="execution-environment-lookup"
|
||||||
label={
|
label={renderLabel(isGlobalDefaultEnvironment, isDefaultEnvironment)}
|
||||||
isDefaultEnvironment
|
|
||||||
? i18n._(t`Default Execution Environment`)
|
|
||||||
: i18n._(t`Execution Environment`)
|
|
||||||
}
|
|
||||||
labelIcon={popoverContent && <Popover content={popoverContent} />}
|
labelIcon={popoverContent && <Popover content={popoverContent} />}
|
||||||
>
|
>
|
||||||
{isDisabled ? (
|
{tooltip ? (
|
||||||
<Tooltip content={tooltip}>{renderLookup()}</Tooltip>
|
<Tooltip content={tooltip}>{renderLookup()}</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
renderLookup()
|
renderLookup()
|
||||||
@@ -180,6 +190,7 @@ ExecutionEnvironmentLookup.propTypes = {
|
|||||||
popoverContent: string,
|
popoverContent: string,
|
||||||
onChange: func.isRequired,
|
onChange: func.isRequired,
|
||||||
isDefaultEnvironment: bool,
|
isDefaultEnvironment: bool,
|
||||||
|
isGlobalDefaultEnvironment: bool,
|
||||||
projectId: oneOfType([number, string]),
|
projectId: oneOfType([number, string]),
|
||||||
organizationId: oneOfType([number, string]),
|
organizationId: oneOfType([number, string]),
|
||||||
};
|
};
|
||||||
@@ -187,6 +198,7 @@ ExecutionEnvironmentLookup.propTypes = {
|
|||||||
ExecutionEnvironmentLookup.defaultProps = {
|
ExecutionEnvironmentLookup.defaultProps = {
|
||||||
popoverContent: '',
|
popoverContent: '',
|
||||||
isDefaultEnvironment: false,
|
isDefaultEnvironment: false,
|
||||||
|
isGlobalDefaultEnvironment: false,
|
||||||
value: null,
|
value: null,
|
||||||
projectId: null,
|
projectId: null,
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
|
|||||||
@@ -10,16 +10,31 @@ import {
|
|||||||
checkPropTypes,
|
checkPropTypes,
|
||||||
} from 'prop-types';
|
} from 'prop-types';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Alert, Button, DropdownItem, Tooltip } from '@patternfly/react-core';
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DropdownItem,
|
||||||
|
Tooltip,
|
||||||
|
} 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 AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import { KebabifiedContext } from '../../contexts/Kebabified';
|
import { KebabifiedContext } from '../../contexts/Kebabified';
|
||||||
|
import { getRelatedResourceDeleteCounts } from '../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
const WarningMessage = styled(Alert)`
|
const WarningMessage = styled(Alert)`
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const Label = styled.span`
|
||||||
|
&& {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const requiredField = props => {
|
const requiredField = props => {
|
||||||
const { name, username, image } = props;
|
const { name, username, image } = props;
|
||||||
if (!name && !username && !image) {
|
if (!name && !username && !image) {
|
||||||
@@ -77,20 +92,43 @@ function ToolbarDeleteButton({
|
|||||||
pluralizedItemName,
|
pluralizedItemName,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
deleteDetailsRequests,
|
||||||
warningMessage,
|
warningMessage,
|
||||||
|
deleteMessage,
|
||||||
i18n,
|
i18n,
|
||||||
cannotDelete,
|
cannotDelete,
|
||||||
}) {
|
}) {
|
||||||
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
const { isKebabified, onKebabModalChange } = useContext(KebabifiedContext);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [deleteDetails, setDeleteDetails] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [deleteMessageError, setDeleteMessageError] = useState();
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
onDelete();
|
onDelete();
|
||||||
toggleModal();
|
toggleModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleModal = () => {
|
const toggleModal = async isOpen => {
|
||||||
setIsModalOpen(!isModalOpen);
|
setIsLoading(true);
|
||||||
|
setDeleteDetails(null);
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
itemsToDelete.length === 1 &&
|
||||||
|
deleteDetailsRequests?.length > 0
|
||||||
|
) {
|
||||||
|
const { results, error } = await getRelatedResourceDeleteCounts(
|
||||||
|
deleteDetailsRequests
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setDeleteMessageError(error);
|
||||||
|
} else {
|
||||||
|
setDeleteDetails(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsModalOpen(isOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,27 +164,84 @@ function ToolbarDeleteButton({
|
|||||||
const isDisabled =
|
const isDisabled =
|
||||||
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
itemsToDelete.length === 0 || itemsToDelete.some(cannotDelete);
|
||||||
|
|
||||||
// NOTE: Once PF supports tooltips on disabled elements,
|
const buildDeleteWarning = () => {
|
||||||
// we can delete the extra <div> around the <DeleteButton> below.
|
const deleteMessages = [];
|
||||||
// See: https://github.com/patternfly/patternfly-react/issues/1894
|
if (warningMessage) {
|
||||||
|
deleteMessages.push(warningMessage);
|
||||||
|
}
|
||||||
|
if (deleteMessage) {
|
||||||
|
if (
|
||||||
|
itemsToDelete[0]?.type !== 'inventory' &&
|
||||||
|
(itemsToDelete.length > 1 || deleteDetails)
|
||||||
|
) {
|
||||||
|
deleteMessages.push(deleteMessage);
|
||||||
|
} else if (deleteDetails || itemsToDelete.length > 1) {
|
||||||
|
deleteMessages.push(deleteMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{deleteMessages.map(message => (
|
||||||
|
<div aria-label={message} key={message}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{deleteDetails &&
|
||||||
|
Object.entries(deleteDetails).map(([key, value]) => (
|
||||||
|
<div key={key} aria-label={`${key}: ${value}`}>
|
||||||
|
<Label>{key}</Label>
|
||||||
|
<Badge>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deleteMessageError) {
|
||||||
|
return (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deleteMessageError}
|
||||||
|
title={i18n._(t`Error!`)}
|
||||||
|
onClose={() => {
|
||||||
|
toggleModal(false);
|
||||||
|
setDeleteMessageError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={deleteMessageError} />
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const shouldShowDeleteWarning =
|
||||||
|
warningMessage ||
|
||||||
|
(itemsToDelete.length === 1 && deleteDetails) ||
|
||||||
|
(itemsToDelete.length > 1 && deleteMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isKebabified ? (
|
{isKebabified ? (
|
||||||
<DropdownItem
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
key="add"
|
<DropdownItem
|
||||||
isDisabled={isDisabled}
|
key="add"
|
||||||
component="button"
|
isDisabled={isDisabled}
|
||||||
onClick={toggleModal}
|
isLoading={isLoading}
|
||||||
>
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
{i18n._(t`Delete`)}
|
component="button"
|
||||||
</DropdownItem>
|
onClick={() => {
|
||||||
|
toggleModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n._(t`Delete`)}
|
||||||
|
</DropdownItem>
|
||||||
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip content={renderTooltip()} position="top">
|
<Tooltip content={renderTooltip()} position="top">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
spinnerAriaValueText={isLoading ? 'Loading' : undefined}
|
||||||
aria-label={i18n._(t`Delete`)}
|
aria-label={i18n._(t`Delete`)}
|
||||||
onClick={toggleModal}
|
onClick={() => toggleModal(true)}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
@@ -154,17 +249,22 @@ function ToolbarDeleteButton({
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
variant="danger"
|
variant="danger"
|
||||||
title={modalTitle}
|
title={modalTitle}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={toggleModal}
|
onClose={() => toggleModal(false)}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
<Button
|
||||||
|
ouiaId="delete-modal-confirm"
|
||||||
key="delete"
|
key="delete"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
aria-label={i18n._(t`confirm delete`)}
|
aria-label={i18n._(t`confirm delete`)}
|
||||||
|
isDisabled={Boolean(
|
||||||
|
deleteDetails && itemsToDelete[0]?.type === 'credential_type'
|
||||||
|
)}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
@@ -173,7 +273,7 @@ function ToolbarDeleteButton({
|
|||||||
key="cancel"
|
key="cancel"
|
||||||
variant="link"
|
variant="link"
|
||||||
aria-label={i18n._(t`cancel delete`)}
|
aria-label={i18n._(t`cancel delete`)}
|
||||||
onClick={toggleModal}
|
onClick={() => toggleModal(false)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -186,8 +286,12 @@ function ToolbarDeleteButton({
|
|||||||
<br />
|
<br />
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{warningMessage && (
|
{shouldShowDeleteWarning && (
|
||||||
<WarningMessage variant="warning" isInline title={warningMessage} />
|
<WarningMessage
|
||||||
|
variant="warning"
|
||||||
|
isInline
|
||||||
|
title={buildDeleteWarning()}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AlertModal>
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import {
|
||||||
|
mountWithContexts,
|
||||||
|
waitForElement,
|
||||||
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
|
import { CredentialsAPI } from '../../api';
|
||||||
import ToolbarDeleteButton from './ToolbarDeleteButton';
|
import ToolbarDeleteButton from './ToolbarDeleteButton';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
const itemA = {
|
const itemA = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Foo',
|
name: 'Foo',
|
||||||
@@ -19,27 +26,180 @@ const itemC = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('<ToolbarDeleteButton />', () => {
|
describe('<ToolbarDeleteButton />', () => {
|
||||||
|
let deleteDetailsRequests;
|
||||||
|
let wrapper;
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteDetailsRequests = [
|
||||||
|
{
|
||||||
|
label: 'Workflow Job Template Node',
|
||||||
|
request: CredentialsAPI.read.mockResolvedValue({ data: { count: 1 } }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
test('should render button', () => {
|
test('should render button', () => {
|
||||||
const wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[]} />
|
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[]} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find('button')).toHaveLength(1);
|
expect(wrapper.find('button')).toHaveLength(1);
|
||||||
expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot();
|
expect(wrapper.find('ToolbarDeleteButton')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should open confirmation modal', () => {
|
test('should open confirmation modal', async () => {
|
||||||
const wrapper = mountWithContexts(
|
await act(async () => {
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
wrapper = mountWithContexts(
|
||||||
);
|
<ToolbarDeleteButton
|
||||||
|
onDelete={() => {}}
|
||||||
|
itemsToDelete={[itemA]}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
warningMessage="Are you sure to want to delete this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(wrapper.find('Modal')).toHaveLength(0);
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
wrapper.find('button').simulate('click');
|
await act(async () => {
|
||||||
wrapper.update();
|
wrapper.find('button').prop('onClick')();
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'Modal', el => el.length > 0);
|
||||||
|
expect(CredentialsAPI.read).toBeCalled();
|
||||||
expect(wrapper.find('Modal')).toHaveLength(1);
|
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('div[aria-label="Workflow Job Template Node: 1"]')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled')
|
||||||
|
).toBe(false);
|
||||||
|
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open confirmation with enabled delete button modal', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ToolbarDeleteButton
|
||||||
|
onDelete={() => {}}
|
||||||
|
itemsToDelete={[
|
||||||
|
{
|
||||||
|
name: 'foo',
|
||||||
|
id: 1,
|
||||||
|
type: 'credential_type',
|
||||||
|
summary_fields: { user_capabilities: { delete: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bar',
|
||||||
|
id: 2,
|
||||||
|
type: 'credential_type',
|
||||||
|
summary_fields: { user_capabilities: { delete: true } },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
warningMessage="Are you sure to want to delete this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button').prop('onClick')();
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'Modal', el => el.length > 0);
|
||||||
|
expect(CredentialsAPI.read).not.toBeCalled();
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled')
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should disable confirm delete button', async () => {
|
||||||
|
const request = [
|
||||||
|
{
|
||||||
|
label: 'Workflow Job Template Node',
|
||||||
|
request: CredentialsAPI.read.mockResolvedValue({ data: { count: 3 } }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ToolbarDeleteButton
|
||||||
|
onDelete={() => {}}
|
||||||
|
itemsToDelete={[
|
||||||
|
{
|
||||||
|
name: 'foo',
|
||||||
|
id: 1,
|
||||||
|
type: 'credential_type',
|
||||||
|
summary_fields: { user_capabilities: { delete: true } },
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
deleteDetailsRequests={request}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
warningMessage="Are you sure to want to delete this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper.find('button').prop('onClick')();
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'Modal', el => el.length > 0);
|
||||||
|
expect(CredentialsAPI.read).toBeCalled();
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
wrapper.find('Button[aria-label="confirm delete"]').prop('isDisabled')
|
||||||
|
).toBe(true);
|
||||||
|
expect(wrapper.find('div[aria-label="Delete this?"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should open delete error modal', async () => {
|
||||||
|
const request = [
|
||||||
|
{
|
||||||
|
label: 'Workflow Job Template Node',
|
||||||
|
request: CredentialsAPI.read.mockRejectedValue(
|
||||||
|
new Error({
|
||||||
|
response: {
|
||||||
|
config: {
|
||||||
|
method: 'get',
|
||||||
|
url: '/api/v2/credentals',
|
||||||
|
},
|
||||||
|
data: 'An error occurred',
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ToolbarDeleteButton
|
||||||
|
onDelete={() => {}}
|
||||||
|
itemsToDelete={[itemA]}
|
||||||
|
deleteDetailsRequests={request}
|
||||||
|
deleteMessage="Delete this?"
|
||||||
|
warningMessage="Are you sure to want to delete this"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('Modal')).toHaveLength(0);
|
||||||
|
await act(async () => wrapper.find('button').simulate('click'));
|
||||||
|
await waitForElement(wrapper, 'Modal', el => el.length > 0);
|
||||||
|
expect(CredentialsAPI.read).toBeCalled();
|
||||||
|
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find('AlertModal[title="Error!"]')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should invoke onDelete prop', () => {
|
test('should invoke onDelete prop', () => {
|
||||||
const onDelete = jest.fn();
|
const onDelete = jest.fn();
|
||||||
const wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
|
<ToolbarDeleteButton onDelete={onDelete} itemsToDelete={[itemA]} />
|
||||||
);
|
);
|
||||||
wrapper.find('button').simulate('click');
|
wrapper.find('button').simulate('click');
|
||||||
@@ -53,14 +213,14 @@ describe('<ToolbarDeleteButton />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should disable button when no delete permissions', () => {
|
test('should disable button when no delete permissions', () => {
|
||||||
const wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemB]} />
|
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemB]} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find('button[disabled]')).toHaveLength(1);
|
expect(wrapper.find('button[disabled]')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render tooltip', () => {
|
test('should render tooltip', () => {
|
||||||
const wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemA]} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||||
@@ -68,7 +228,7 @@ describe('<ToolbarDeleteButton />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render tooltip for username', () => {
|
test('should render tooltip for username', () => {
|
||||||
const wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemC]} />
|
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemC]} />
|
||||||
);
|
);
|
||||||
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
expect(wrapper.find('Tooltip')).toHaveLength(1);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
|
|||||||
<Button
|
<Button
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
isDisabled={true}
|
isDisabled={true}
|
||||||
|
isLoading={false}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
@@ -93,13 +94,14 @@ exports[`<ToolbarDeleteButton /> should render button 1`] = `
|
|||||||
<Button
|
<Button
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
isDisabled={true}
|
isDisabled={true}
|
||||||
|
isLoading={false}
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled={true}
|
aria-disabled={true}
|
||||||
aria-label="Delete"
|
aria-label="Delete"
|
||||||
className="pf-c-button pf-m-secondary pf-m-disabled"
|
className="pf-c-button pf-m-secondary pf-m-disabled pf-m-progress"
|
||||||
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
||||||
data-ouia-component-type="PF4/Button"
|
data-ouia-component-type="PF4/Button"
|
||||||
data-ouia-safe={true}
|
data-ouia-safe={true}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getQSConfig, parseQueryString } from '../../util/qs';
|
|||||||
import useWsTemplates from '../../util/useWsTemplates';
|
import useWsTemplates from '../../util/useWsTemplates';
|
||||||
import AddDropDownButton from '../AddDropDownButton';
|
import AddDropDownButton from '../AddDropDownButton';
|
||||||
import TemplateListItem from './TemplateListItem';
|
import TemplateListItem from './TemplateListItem';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function TemplateList({ defaultParams, i18n }) {
|
function TemplateList({ defaultParams, i18n }) {
|
||||||
// The type value in const qsConfig below does not have a space between job_template and
|
// The type value in const qsConfig below does not have a space between job_template and
|
||||||
@@ -168,6 +169,11 @@ function TemplateList({ defaultParams, i18n }) {
|
|||||||
<AddDropDownButton key="add" dropdownItems={addDropDownButton} />
|
<AddDropDownButton key="add" dropdownItems={addDropDownButton} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.template(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -236,6 +242,11 @@ function TemplateList({ defaultParams, i18n }) {
|
|||||||
onDelete={handleTemplateDelete}
|
onDelete={handleTemplateDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Templates`)}
|
pluralizedItemName={i18n._(t`Templates`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This template is currently being used by some workflow nodes. Are you sure you want to delete it?} other {Deleting these templates could impact some workflow nodes that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import ErrorDetail from '../../../components/ErrorDetail';
|
|||||||
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
|
import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
|
||||||
import { Credential } from '../../../types';
|
import { Credential } from '../../../types';
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const PluginInputMetadata = styled(CodeEditor)`
|
const PluginInputMetadata = styled(CodeEditor)`
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
@@ -183,6 +184,11 @@ function CredentialDetail({ i18n, credential }) {
|
|||||||
fetchDetails();
|
fetchDetails();
|
||||||
}, [fetchDetails]);
|
}, [fetchDetails]);
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
|
||||||
|
credential,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
@@ -270,9 +276,14 @@ function CredentialDetail({ i18n, credential }) {
|
|||||||
{user_capabilities.delete && (
|
{user_capabilities.delete && (
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
name={name}
|
name={name}
|
||||||
|
itemToDelete={credential}
|
||||||
modalTitle={i18n._(t`Delete Credential`)}
|
modalTitle={i18n._(t`Delete Credential`)}
|
||||||
onConfirm={deleteCredential}
|
onConfirm={deleteCredential}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This credential is currently being used by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ describe('<CredentialDetail />', () => {
|
|||||||
expect(wrapper.find('CredentialDetail').length).toBe(1);
|
expect(wrapper.find('CredentialDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
test('should render details', () => {
|
test('should render details', () => {
|
||||||
expectDetailToMatch(wrapper, 'Name', mockCredential.name);
|
expectDetailToMatch(wrapper, 'Name', mockCredential.name);
|
||||||
expectDetailToMatch(wrapper, 'Description', mockCredential.description);
|
expectDetailToMatch(wrapper, 'Description', mockCredential.description);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useHistory, useParams } from 'react-router-dom';
|
import { useHistory, useParams } from 'react-router-dom';
|
||||||
import { object } from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { CardBody } from '../../../components/Card';
|
import { CardBody } from '../../../components/Card';
|
||||||
import {
|
import {
|
||||||
CredentialsAPI,
|
CredentialsAPI,
|
||||||
@@ -197,8 +197,8 @@ function CredentialEdit({ credential }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
CredentialEdit.proptype = {
|
CredentialEdit.propTypes = {
|
||||||
inventory: object.isRequired,
|
credential: PropTypes.objectOf(PropTypes.object).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { CredentialEdit as _CredentialEdit };
|
export { CredentialEdit as _CredentialEdit };
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } 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 { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { CredentialsAPI } from '../../../api';
|
import { CredentialsAPI } from '../../../api';
|
||||||
|
import useSelected from '../../../util/useSelected';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import DataListToolbar from '../../../components/DataListToolbar';
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
@@ -18,6 +19,7 @@ import PaginatedTable, {
|
|||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import CredentialListItem from './CredentialListItem';
|
import CredentialListItem from './CredentialListItem';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('credential', {
|
const QS_CONFIG = getQSConfig('credential', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -26,9 +28,7 @@ const QS_CONFIG = getQSConfig('credential', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function CredentialList({ i18n }) {
|
function CredentialList({ i18n }) {
|
||||||
const [selected, setSelected] = useState([]);
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: {
|
result: {
|
||||||
credentials,
|
credentials,
|
||||||
@@ -77,8 +77,10 @@ function CredentialList({ i18n }) {
|
|||||||
fetchCredentials();
|
fetchCredentials();
|
||||||
}, [fetchCredentials]);
|
}, [fetchCredentials]);
|
||||||
|
|
||||||
const isAllSelected =
|
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
|
||||||
selected.length > 0 && selected.length === credentials.length;
|
credentials
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading: isDeleteLoading,
|
isLoading: isDeleteLoading,
|
||||||
deleteItems: deleteCredentials,
|
deleteItems: deleteCredentials,
|
||||||
@@ -100,21 +102,12 @@ function CredentialList({ i18n }) {
|
|||||||
setSelected([]);
|
setSelected([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = isSelected => {
|
|
||||||
setSelected(isSelected ? [...credentials] : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = row => {
|
|
||||||
if (selected.some(s => s.id === row.id)) {
|
|
||||||
setSelected(selected.filter(s => s.id !== row.id));
|
|
||||||
} else {
|
|
||||||
setSelected(selected.concat(row));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canAdd =
|
const canAdd =
|
||||||
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.credential(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -169,7 +162,9 @@ function CredentialList({ i18n }) {
|
|||||||
{...props}
|
{...props}
|
||||||
showSelectAll
|
showSelectAll
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={isSelected =>
|
||||||
|
setSelected(isSelected ? [...credentials] : [])
|
||||||
|
}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAdd
|
...(canAdd
|
||||||
@@ -180,6 +175,11 @@ function CredentialList({ i18n }) {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Credentials`)}
|
pluralizedItemName={i18n._(t`Credentials`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This credential is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these credentials could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ describe('<CredentialList />', () => {
|
|||||||
expect(wrapper.find('CredentialList').length).toBe(1);
|
expect(wrapper.find('CredentialList').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
test('should fetch credentials from api and render the in the list', () => {
|
test('should fetch credentials from api and render the in the list', () => {
|
||||||
expect(CredentialsAPI.read).toHaveBeenCalled();
|
expect(CredentialsAPI.read).toHaveBeenCalled();
|
||||||
expect(wrapper.find('CredentialListItem').length).toBe(5);
|
expect(wrapper.find('CredentialListItem').length).toBe(5);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback } 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 { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { CredentialTypesAPI } from '../../../api';
|
import { CredentialTypesAPI } from '../../../api';
|
||||||
import { jsonToYaml } from '../../../util/yaml';
|
import { jsonToYaml } from '../../../util/yaml';
|
||||||
|
import {
|
||||||
|
relatedResourceDeleteRequests,
|
||||||
|
getRelatedResourceDeleteCounts,
|
||||||
|
} from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
|
|
||||||
function CredentialTypeDetails({ credentialType, i18n }) {
|
function CredentialTypeDetails({ credentialType, i18n }) {
|
||||||
const { id, name, description, injectors, inputs } = credentialType;
|
const { id, name, description, injectors, inputs } = credentialType;
|
||||||
@@ -32,7 +37,35 @@ function CredentialTypeDetails({ credentialType, i18n }) {
|
|||||||
}, [id, history])
|
}, [id, history])
|
||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const {
|
||||||
|
result: { isDeleteDisabled },
|
||||||
|
error: deleteDetailsError,
|
||||||
|
request: fetchDeleteDetails,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const {
|
||||||
|
results: deleteDetails,
|
||||||
|
error,
|
||||||
|
} = await getRelatedResourceDeleteCounts(
|
||||||
|
relatedResourceDeleteRequests.credentialType(credentialType, i18n)
|
||||||
|
);
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
if (deleteDetails) {
|
||||||
|
return { isDeleteDisabled: true };
|
||||||
|
}
|
||||||
|
return { isDeleteDisabled: false };
|
||||||
|
}, [credentialType, i18n]),
|
||||||
|
{ isDeleteDisabled: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDeleteDetails();
|
||||||
|
}, [fetchDeleteDetails]);
|
||||||
|
const { error, dismissError } = useDismissableError(
|
||||||
|
deleteError || deleteDetailsError
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -82,7 +115,13 @@ function CredentialTypeDetails({ credentialType, i18n }) {
|
|||||||
name={name}
|
name={name}
|
||||||
modalTitle={i18n._(t`Delete credential type`)}
|
modalTitle={i18n._(t`Delete credential type`)}
|
||||||
onConfirm={deleteCredentialType}
|
onConfirm={deleteCredentialType}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading || isDeleteDisabled}
|
||||||
|
disabledTooltip={
|
||||||
|
isDeleteDisabled &&
|
||||||
|
i18n._(
|
||||||
|
t`This credential type is currently being used by some credentials and cannot be deleted`
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
@@ -95,7 +134,9 @@ function CredentialTypeDetails({ credentialType, i18n }) {
|
|||||||
onClose={dismissError}
|
onClose={dismissError}
|
||||||
title={i18n._(t`Error`)}
|
title={i18n._(t`Error`)}
|
||||||
variant="error"
|
variant="error"
|
||||||
/>
|
>
|
||||||
|
<ErrorDetail error={error} />
|
||||||
|
</AlertModal>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 { CredentialTypesAPI } from '../../../api';
|
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
|
||||||
import { jsonToYaml } from '../../../util/yaml';
|
import { jsonToYaml } from '../../../util/yaml';
|
||||||
|
|
||||||
import CredentialTypeDetails from './CredentialTypeDetails';
|
import CredentialTypeDetails from './CredentialTypeDetails';
|
||||||
@@ -66,6 +66,10 @@ function expectDetailToMatch(wrapper, label, value) {
|
|||||||
|
|
||||||
describe('<CredentialTypeDetails/>', () => {
|
describe('<CredentialTypeDetails/>', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.unmount();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
test('should render details properly', async () => {
|
test('should render details properly', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
@@ -92,6 +96,38 @@ describe('<CredentialTypeDetails/>', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should disabled delete and show proper tooltip requests', async () => {
|
||||||
|
CredentialsAPI.read.mockResolvedValue({ data: { count: 15 } });
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialTypeDetails credentialType={credentialTypeData} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
|
||||||
|
expect(wrapper.find('DeleteButton').prop('disabledTooltip')).toBe(
|
||||||
|
'This credential type is currently being used by some credentials and cannot be deleted'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Tooltip').length).toBe(1);
|
||||||
|
expect(wrapper.find('Tooltip').prop('content')).toBe(
|
||||||
|
'This credential type is currently being used by some credentials and cannot be deleted'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw error', async () => {
|
||||||
|
CredentialsAPI.read.mockRejectedValue(new Error('error'));
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<CredentialTypeDetails credentialType={credentialTypeData} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('ErrorDetail').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('expected api call is made for delete', async () => {
|
test('expected api call is made for delete', async () => {
|
||||||
const history = createMemoryHistory({
|
const history = createMemoryHistory({
|
||||||
initialEntries: ['/credential_types/42/details'],
|
initialEntries: ['/credential_types/42/details'],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import PaginatedTable, {
|
|||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
import CredentialTypeListItem from './CredentialTypeListItem';
|
import CredentialTypeListItem from './CredentialTypeListItem';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('credential-type', {
|
const QS_CONFIG = getQSConfig('credential-type', {
|
||||||
@@ -106,6 +106,11 @@ function CredentialTypeList({ i18n }) {
|
|||||||
|
|
||||||
const canAdd = actions && actions.POST;
|
const canAdd = actions && actions.POST;
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.credentialType(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
@@ -162,6 +167,11 @@ function CredentialTypeList({ i18n }) {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Credential Types`)}
|
pluralizedItemName={i18n._(t`Credential Types`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This credential type is currently being used by some credentials and cannot be deleted.} other {Credential types that are being used by credentials cannot be deleted. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import { CredentialTypesAPI } from '../../../api';
|
import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
|
||||||
import CredentialTypeList from './CredentialTypeList';
|
import CredentialTypeList from './CredentialTypeList';
|
||||||
|
|
||||||
jest.mock('../../../api/models/CredentialTypes');
|
jest.mock('../../../api/models/CredentialTypes');
|
||||||
|
jest.mock('../../../api/models/Credentials');
|
||||||
|
|
||||||
const credentialTypes = {
|
const credentialTypes = {
|
||||||
data: {
|
data: {
|
||||||
@@ -49,6 +50,12 @@ describe('<CredentialTypeList', () => {
|
|||||||
await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0);
|
await waitForElement(wrapper, 'CredentialTypeList', el => el.length > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should have data fetched and render 2 rows', async () => {
|
test('should have data fetched and render 2 rows', async () => {
|
||||||
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
|
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
|
||||||
CredentialTypesAPI.readOptions.mockResolvedValue(options);
|
CredentialTypesAPI.readOptions.mockResolvedValue(options);
|
||||||
@@ -65,6 +72,7 @@ describe('<CredentialTypeList', () => {
|
|||||||
test('should delete item successfully', async () => {
|
test('should delete item successfully', async () => {
|
||||||
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
|
CredentialTypesAPI.read.mockResolvedValue(credentialTypes);
|
||||||
CredentialTypesAPI.readOptions.mockResolvedValue(options);
|
CredentialTypesAPI.readOptions.mockResolvedValue(options);
|
||||||
|
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<CredentialTypeList />);
|
wrapper = mountWithContexts(<CredentialTypeList />);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { toTitleCase } from '../../../util/strings';
|
import { toTitleCase } from '../../../util/strings';
|
||||||
import { ExecutionEnvironmentsAPI } from '../../../api';
|
import { ExecutionEnvironmentsAPI } from '../../../api';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) {
|
function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -41,7 +42,10 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment(
|
||||||
|
executionEnvironment,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
@@ -120,6 +124,10 @@ function ExecutionEnvironmentDetails({ executionEnvironment, i18n }) {
|
|||||||
onConfirm={deleteExecutionEnvironment}
|
onConfirm={deleteExecutionEnvironment}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
ouiaId="delete-button"
|
ouiaId="delete-button"
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This execution environment is currently being used by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -175,4 +175,22 @@ describe('<ExecutionEnvironmentDetails/>', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]')).toHaveLength(0);
|
expect(wrapper.find('Button[aria-label="Delete"]')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/execution_environments/42/details'],
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<ExecutionEnvironmentDetails
|
||||||
|
executionEnvironment={executionEnvironment}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: { router: { history } },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,22 @@ import {
|
|||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import { ExecutionEnvironmentsAPI } from '../../../api';
|
import {
|
||||||
|
ExecutionEnvironmentsAPI,
|
||||||
|
InventorySourcesAPI,
|
||||||
|
WorkflowJobTemplateNodesAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
ProjectsAPI,
|
||||||
|
UnifiedJobTemplatesAPI,
|
||||||
|
} from '../../../api';
|
||||||
import ExecutionEnvironmentList from './ExecutionEnvironmentList';
|
import ExecutionEnvironmentList from './ExecutionEnvironmentList';
|
||||||
|
|
||||||
jest.mock('../../../api/models/ExecutionEnvironments');
|
jest.mock('../../../api/models/ExecutionEnvironments');
|
||||||
|
jest.mock('../../../api/models/UnifiedJobTemplates');
|
||||||
|
jest.mock('../../../api/models/Projects');
|
||||||
|
jest.mock('../../../api/models/Organizations');
|
||||||
|
jest.mock('../../../api/models/InventorySources');
|
||||||
|
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
|
||||||
|
|
||||||
const executionEnvironments = {
|
const executionEnvironments = {
|
||||||
data: {
|
data: {
|
||||||
@@ -43,6 +55,16 @@ describe('<ExecutionEnvironmentList/>', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ExecutionEnvironmentsAPI.read.mockResolvedValue(executionEnvironments);
|
ExecutionEnvironmentsAPI.read.mockResolvedValue(executionEnvironments);
|
||||||
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue(options);
|
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue(options);
|
||||||
|
InventorySourcesAPI.read.mockResolvedValue({
|
||||||
|
data: { results: [{ id: 10000000 }] },
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
|
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
|
ProjectsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -144,6 +166,11 @@ describe('<ExecutionEnvironmentList/>', () => {
|
|||||||
);
|
);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Button[aria-label="confirm delete"]',
|
||||||
|
el => el.length > 0
|
||||||
|
);
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
|
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
|
||||||
);
|
);
|
||||||
@@ -185,4 +212,17 @@ describe('<ExecutionEnvironmentList/>', () => {
|
|||||||
waitForElement(wrapper, 'ExecutionEnvironmentList', el => el.length > 0);
|
waitForElement(wrapper, 'ExecutionEnvironmentList', el => el.length > 0);
|
||||||
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
expect(wrapper.find('ToolbarAddButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
ExecutionEnvironmentsAPI.read.mockResolvedValue(executionEnvironments);
|
||||||
|
ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({
|
||||||
|
data: { actions: { POST: false } },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<ExecutionEnvironmentList />);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import PaginatedTable, {
|
|||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
import ExecutionEnvironmentsListItem from './ExecutionEnvironmentListItem';
|
import ExecutionEnvironmentsListItem from './ExecutionEnvironmentListItem';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('execution_environments', {
|
const QS_CONFIG = getQSConfig('execution_environments', {
|
||||||
@@ -105,7 +105,10 @@ function ExecutionEnvironmentList({ i18n }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canAdd = actions && actions.POST;
|
const canAdd = actions && actions.POST;
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.executionEnvironment(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
@@ -181,6 +184,11 @@ function ExecutionEnvironmentList({ i18n }) {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Execution Environments`)}
|
pluralizedItemName={i18n._(t`Execution Environments`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This execution environment is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these execution environemnts could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -101,7 +101,6 @@
|
|||||||
"VIRTUAL_ENV": "/var/lib/awx/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",
|
|
||||||
"ANSIBLE_LIBRARY": "/awx_devel/awx/plugins/library",
|
"ANSIBLE_LIBRARY": "/awx_devel/awx/plugins/library",
|
||||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||||
"AWX_GROUP_QUEUES": "tower",
|
"AWX_GROUP_QUEUES": "tower",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { jsonToYaml, isJsonString } from '../../../util/yaml';
|
import { jsonToYaml, isJsonString } from '../../../util/yaml';
|
||||||
import { InstanceGroupsAPI } from '../../../api';
|
import { InstanceGroupsAPI } from '../../../api';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function ContainerGroupDetails({ instanceGroup, i18n }) {
|
function ContainerGroupDetails({ instanceGroup, i18n }) {
|
||||||
const { id, name } = instanceGroup;
|
const { id, name } = instanceGroup;
|
||||||
@@ -34,7 +35,10 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||||
|
instanceGroup,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
@@ -101,6 +105,10 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
|
|||||||
modalTitle={i18n._(t`Delete instance group`)}
|
modalTitle={i18n._(t`Delete instance group`)}
|
||||||
onConfirm={deleteInstanceGroup}
|
onConfirm={deleteInstanceGroup}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This container group is currently being by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '../../../components/DetailList';
|
} from '../../../components/DetailList';
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { InstanceGroupsAPI } from '../../../api';
|
import { InstanceGroupsAPI } from '../../../api';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const Unavailable = styled.span`
|
const Unavailable = styled.span`
|
||||||
color: var(--pf-global--danger-color--200);
|
color: var(--pf-global--danger-color--200);
|
||||||
@@ -38,7 +39,10 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||||
|
instanceGroup,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
const verifyInstanceGroup = item => {
|
const verifyInstanceGroup = item => {
|
||||||
if (item.is_isolated) {
|
if (item.is_isolated) {
|
||||||
return (
|
return (
|
||||||
@@ -142,6 +146,10 @@ function InstanceGroupDetails({ instanceGroup, i18n }) {
|
|||||||
modalTitle={i18n._(t`Delete instance group`)}
|
modalTitle={i18n._(t`Delete instance group`)}
|
||||||
onConfirm={deleteInstanceGroup}
|
onConfirm={deleteInstanceGroup}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This instance group is currently being by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import ErrorDetail from '../../../components/ErrorDetail';
|
|||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
import InstanceGroupListItem from './InstanceGroupListItem';
|
import InstanceGroupListItem from './InstanceGroupListItem';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('instance-group', {
|
const QS_CONFIG = getQSConfig('instance-group', {
|
||||||
@@ -186,7 +186,10 @@ function InstanceGroupList({ i18n }) {
|
|||||||
? `${match.url}/container_group/${item.id}/details`
|
? `${match.url}/container_group/${item.id}/details`
|
||||||
: `${match.url}/${item.id}/details`;
|
: `${match.url}/${item.id}/details`;
|
||||||
};
|
};
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.instanceGroup(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageSection>
|
<PageSection>
|
||||||
@@ -218,6 +221,11 @@ function InstanceGroupList({ i18n }) {
|
|||||||
itemsToDelete={modifiedSelected}
|
itemsToDelete={modifiedSelected}
|
||||||
pluralizedItemName={i18n._(t`Instance Groups`)}
|
pluralizedItemName={i18n._(t`Instance Groups`)}
|
||||||
errorMessage={errorMessageDelete}
|
errorMessage={errorMessageDelete}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This instance group is currently being by other resources. Are you sure you want to delete it?} other {Deleting these instance groups could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import {
|
|||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import { InstanceGroupsAPI } from '../../../api';
|
import {
|
||||||
|
InstanceGroupsAPI,
|
||||||
|
OrganizationsAPI,
|
||||||
|
InventoriesAPI,
|
||||||
|
UnifiedJobTemplatesAPI,
|
||||||
|
} from '../../../api';
|
||||||
import InstanceGroupList from './InstanceGroupList';
|
import InstanceGroupList from './InstanceGroupList';
|
||||||
|
|
||||||
jest.mock('../../../api/models/InstanceGroups');
|
jest.mock('../../../api/models/InstanceGroups');
|
||||||
|
jest.mock('../../../api/models/Organizations');
|
||||||
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/UnifiedJobTemplates');
|
||||||
|
|
||||||
const instanceGroups = {
|
const instanceGroups = {
|
||||||
data: {
|
data: {
|
||||||
@@ -44,6 +52,9 @@ const instanceGroups = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const options = { data: { actions: { POST: true } } };
|
const options = { data: { actions: { POST: true } } };
|
||||||
|
OrganizationsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
InventoriesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
describe('<InstanceGroupList />', () => {
|
describe('<InstanceGroupList />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import ChipGroup from '../../../components/ChipGroup';
|
|||||||
import { InventoriesAPI } from '../../../api';
|
import { InventoriesAPI } from '../../../api';
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { Inventory } from '../../../types';
|
import { Inventory } from '../../../types';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function InventoryDetail({ inventory, i18n }) {
|
function InventoryDetail({ inventory, i18n }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -54,6 +55,11 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
user_capabilities: userCapabilities,
|
user_capabilities: userCapabilities,
|
||||||
} = inventory.summary_fields;
|
} = inventory.summary_fields;
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(
|
||||||
|
inventory,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
@@ -126,6 +132,10 @@ function InventoryDetail({ inventory, i18n }) {
|
|||||||
name={inventory.name}
|
name={inventory.name}
|
||||||
modalTitle={i18n._(t`Delete Inventory`)}
|
modalTitle={i18n._(t`Delete Inventory`)}
|
||||||
onConfirm={deleteInventory}
|
onConfirm={deleteInventory}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This inventory is currently being used by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -105,6 +105,18 @@ describe('<InventoryDetail />', () => {
|
|||||||
expect(dates.at(1).prop('date')).toEqual(mockInventory.modified);
|
expect(dates.at(1).prop('date')).toEqual(mockInventory.modified);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventoryDetail inventory={mockInventory} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('should load instance groups', async () => {
|
test('should load instance groups', async () => {
|
||||||
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getQSConfig, parseQueryString } from '../../../util/qs';
|
|||||||
import useWsInventories from './useWsInventories';
|
import useWsInventories from './useWsInventories';
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||||
import InventoryListItem from './InventoryListItem';
|
import InventoryListItem from './InventoryListItem';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('inventory', {
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -126,6 +127,12 @@ function InventoryList({ i18n }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
const addInventory = i18n._(t`Add inventory`);
|
const addInventory = i18n._(t`Add inventory`);
|
||||||
const addSmartInventory = i18n._(t`Add smart inventory`);
|
const addSmartInventory = i18n._(t`Add smart inventory`);
|
||||||
const addButton = (
|
const addButton = (
|
||||||
@@ -216,6 +223,11 @@ function InventoryList({ i18n }) {
|
|||||||
'{numItemsToDelete, plural, one {The inventory will be in a pending status until the final delete is processed.} other {The inventories will be in a pending status until the final delete is processed.}}',
|
'{numItemsToDelete, plural, one {The inventory will be in a pending status until the final delete is processed.} other {The inventories will be in a pending status until the final delete is processed.}}',
|
||||||
{ numItemsToDelete: selected.length }
|
{ numItemsToDelete: selected.length }
|
||||||
)}
|
)}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This inventory is currently being used by other resources. Are you sure you want to delete it?} other {Deleting these inventories could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { InventoriesAPI } from '../../../api';
|
import {
|
||||||
|
InventoriesAPI,
|
||||||
|
JobTemplatesAPI,
|
||||||
|
WorkflowJobTemplatesAPI,
|
||||||
|
} from '../../../api';
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import InventoryList from './InventoryList';
|
import InventoryList from './InventoryList';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/JobTemplates');
|
||||||
|
jest.mock('../../../api/models/WorkflowJobTemplates');
|
||||||
|
|
||||||
const mockInventories = [
|
const mockInventories = [
|
||||||
{
|
{
|
||||||
@@ -136,6 +142,8 @@ describe('<InventoryList />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
JobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
WorkflowJobTemplatesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||||
global.console.debug = () => {};
|
global.console.debug = () => {};
|
||||||
});
|
});
|
||||||
@@ -155,6 +163,16 @@ describe('<InventoryList />', () => {
|
|||||||
expect(wrapper.find('InventoryListItem')).toHaveLength(3);
|
expect(wrapper.find('InventoryListItem')).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
let wrapper;
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<InventoryList />);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('should select inventory when checked', async () => {
|
test('should select inventory when checked', async () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import ErrorDetail from '../../../components/ErrorDetail';
|
|||||||
import Popover from '../../../components/Popover';
|
import Popover from '../../../components/Popover';
|
||||||
import useRequest from '../../../util/useRequest';
|
import useRequest from '../../../util/useRequest';
|
||||||
import { InventorySourcesAPI } from '../../../api';
|
import { InventorySourcesAPI } from '../../../api';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function InventorySourceDetail({ inventorySource, i18n }) {
|
function InventorySourceDetail({ inventorySource, i18n }) {
|
||||||
const {
|
const {
|
||||||
@@ -96,6 +97,12 @@ function InventorySourceDetail({ inventorySource, i18n }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
|
||||||
|
inventorySource.inventory,
|
||||||
|
i18n,
|
||||||
|
inventorySource
|
||||||
|
);
|
||||||
|
|
||||||
const VERBOSITY = {
|
const VERBOSITY = {
|
||||||
0: i18n._(t`0 (Warning)`),
|
0: i18n._(t`0 (Warning)`),
|
||||||
1: i18n._(t`1 (Info)`),
|
1: i18n._(t`1 (Info)`),
|
||||||
@@ -281,6 +288,10 @@ function InventorySourceDetail({ inventorySource, i18n }) {
|
|||||||
name={name}
|
name={name}
|
||||||
modalTitle={i18n._(t`Delete inventory source`)}
|
modalTitle={i18n._(t`Delete inventory source`)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This inventory source is currently being used by other resources that rely on it. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -7,9 +7,20 @@ import {
|
|||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../../testUtils/enzymeHelpers';
|
||||||
import InventorySourceDetail from './InventorySourceDetail';
|
import InventorySourceDetail from './InventorySourceDetail';
|
||||||
import mockInvSource from '../shared/data.inventory_source.json';
|
import mockInvSource from '../shared/data.inventory_source.json';
|
||||||
import { InventorySourcesAPI } from '../../../api';
|
import {
|
||||||
|
InventorySourcesAPI,
|
||||||
|
InventoriesAPI,
|
||||||
|
WorkflowJobTemplateNodesAPI,
|
||||||
|
} from '../../../api';
|
||||||
|
|
||||||
jest.mock('../../../api/models/InventorySources');
|
jest.mock('../../../api/models/InventorySources');
|
||||||
|
jest.mock('../../../api/models/Inventories');
|
||||||
|
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
|
||||||
|
|
||||||
|
InventoriesAPI.updateSources.mockResolvedValue({
|
||||||
|
data: [{ inventory_source: 1 }],
|
||||||
|
});
|
||||||
|
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
InventorySourcesAPI.readOptions.mockResolvedValue({
|
InventorySourcesAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -101,6 +112,17 @@ describe('InventorySourceDetail', () => {
|
|||||||
expect(wrapper.find('InventorySourceSyncButton')).toHaveLength(1);
|
expect(wrapper.find('InventorySourceSyncButton')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<InventorySourceDetail inventorySource={mockInvSource} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
wrapper.find('DeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
test('should hide expected action buttons for users without permissions', async () => {
|
test('should hide expected action buttons for users without permissions', async () => {
|
||||||
const userCapabilities = {
|
const userCapabilities = {
|
||||||
edit: false,
|
edit: false,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import AlertModal from '../../../components/AlertModal/AlertModal';
|
|||||||
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
|
||||||
import InventorySourceListItem from './InventorySourceListItem';
|
import InventorySourceListItem from './InventorySourceListItem';
|
||||||
import useWsInventorySources from './useWsInventorySources';
|
import useWsInventorySources from './useWsInventorySources';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('inventory', {
|
const QS_CONFIG = getQSConfig('inventory', {
|
||||||
not__source: '',
|
not__source: '',
|
||||||
@@ -142,6 +143,12 @@ function InventorySourceList({ i18n }) {
|
|||||||
sourceChoicesOptions &&
|
sourceChoicesOptions &&
|
||||||
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
|
Object.prototype.hasOwnProperty.call(sourceChoicesOptions, 'POST');
|
||||||
const listUrl = `/inventories/${inventoryType}/${id}/sources/`;
|
const listUrl = `/inventories/${inventoryType}/${id}/sources/`;
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.inventorySource(
|
||||||
|
id,
|
||||||
|
i18n,
|
||||||
|
selected[0]
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedDataList
|
||||||
@@ -174,6 +181,11 @@ function InventorySourceList({ i18n }) {
|
|||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Inventory Sources`)}
|
pluralizedItemName={i18n._(t`Inventory Sources`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This inventory source is currently being used by other resources that rely on it. Are you sure you want to delete it?} other {Deleting these inventory sources could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
...(canSyncSources
|
...(canSyncSources
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import React from 'react';
|
|||||||
import { Route } from 'react-router-dom';
|
import { Route } from 'react-router-dom';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { InventoriesAPI, InventorySourcesAPI } from '../../../api';
|
import {
|
||||||
|
InventoriesAPI,
|
||||||
|
InventorySourcesAPI,
|
||||||
|
WorkflowJobTemplateNodesAPI,
|
||||||
|
} from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -13,6 +17,7 @@ import InventorySourceList from './InventorySourceList';
|
|||||||
jest.mock('../../../api/models/InventorySources');
|
jest.mock('../../../api/models/InventorySources');
|
||||||
jest.mock('../../../api/models/Inventories');
|
jest.mock('../../../api/models/Inventories');
|
||||||
jest.mock('../../../api/models/InventoryUpdates');
|
jest.mock('../../../api/models/InventoryUpdates');
|
||||||
|
jest.mock('../../../api/models/WorkflowJobTemplateNodes');
|
||||||
|
|
||||||
const sources = {
|
const sources = {
|
||||||
data: {
|
data: {
|
||||||
@@ -61,6 +66,12 @@ describe('<InventorySourceList />', () => {
|
|||||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||||
global.console.debug = () => {};
|
global.console.debug = () => {};
|
||||||
InventoriesAPI.readSources.mockResolvedValue(sources);
|
InventoriesAPI.readSources.mockResolvedValue(sources);
|
||||||
|
InventoriesAPI.updateSources.mockResolvedValue({
|
||||||
|
data: [{ inventory_source: 1 }],
|
||||||
|
});
|
||||||
|
InventorySourcesAPI.readGroups.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
InventorySourcesAPI.readHosts.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
WorkflowJobTemplateNodesAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
InventorySourcesAPI.readOptions.mockResolvedValue({
|
InventorySourcesAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
actions: {
|
actions: {
|
||||||
@@ -119,6 +130,12 @@ describe('<InventorySourceList />', () => {
|
|||||||
expect(InventorySourcesAPI.readOptions).toHaveBeenCalled();
|
expect(InventorySourcesAPI.readOptions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
test('source data should render properly', async () => {
|
test('source data should render properly', async () => {
|
||||||
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
|
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -122,20 +122,14 @@ const SCMSubForm = ({ autoPopulateProject, i18n }) => {
|
|||||||
onSelect={(event, value) => {
|
onSelect={(event, value) => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
value = value.trim();
|
value = value.trim();
|
||||||
if (!value.endsWith('/')) {
|
|
||||||
value += '/';
|
|
||||||
}
|
|
||||||
sourcePathHelpers.setValue(value);
|
sourcePathHelpers.setValue(value);
|
||||||
}}
|
}}
|
||||||
aria-label={i18n._(t`Select source path`)}
|
aria-label={i18n._(t`Select source path`)}
|
||||||
placeholder={i18n._(t`Select source path`)}
|
placeholder={i18n._(t`Select source path`)}
|
||||||
|
createText={i18n._(t`Set source path to`)}
|
||||||
isCreatable
|
isCreatable
|
||||||
onCreateOption={value => {
|
onCreateOption={value => {
|
||||||
value.trim();
|
value.trim();
|
||||||
|
|
||||||
if (!value.endsWith('/')) {
|
|
||||||
value += '/';
|
|
||||||
}
|
|
||||||
setSourcePath([...sourcePath, value]);
|
setSourcePath([...sourcePath, value]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('<SCMSubForm />', () => {
|
|||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(wrapper.find('Select#source_path').prop('selections')).toEqual(
|
expect(wrapper.find('Select#source_path').prop('selections')).toEqual(
|
||||||
'bar/'
|
'bar'
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -138,7 +138,7 @@ describe('<SCMSubForm />', () => {
|
|||||||
customWrapper.find('Select').invoke('onSelect')({}, 'newPath');
|
customWrapper.find('Select').invoke('onSelect')({}, 'newPath');
|
||||||
});
|
});
|
||||||
customWrapper.update();
|
customWrapper.update();
|
||||||
expect(customWrapper.find('Select').prop('selections')).toBe('newPath/');
|
expect(customWrapper.find('Select').prop('selections')).toBe('newPath');
|
||||||
});
|
});
|
||||||
test('Update on project update should be disabled', async () => {
|
test('Update on project update should be disabled', async () => {
|
||||||
const customInitialValues = {
|
const customInitialValues = {
|
||||||
|
|||||||
@@ -101,7 +101,6 @@
|
|||||||
"VIRTUAL_ENV": "/var/lib/awx/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",
|
|
||||||
"ANSIBLE_LIBRARY": "/awx_devel/awx/plugins/library",
|
"ANSIBLE_LIBRARY": "/awx_devel/awx/plugins/library",
|
||||||
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
"SDB_NOTIFY_HOST": "docker.for.mac.host.internal",
|
||||||
"AWX_GROUP_QUEUES": "tower",
|
"AWX_GROUP_QUEUES": "tower",
|
||||||
|
|||||||
@@ -12,20 +12,32 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { CaretLeftIcon } from '@patternfly/react-icons';
|
import { CaretLeftIcon } from '@patternfly/react-icons';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { JobsAPI } from '../../api';
|
|
||||||
import ContentError from '../../components/ContentError';
|
import ContentError from '../../components/ContentError';
|
||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import RoutedTabs from '../../components/RoutedTabs';
|
import RoutedTabs from '../../components/RoutedTabs';
|
||||||
import useRequest from '../../util/useRequest';
|
import useRequest from '../../util/useRequest';
|
||||||
|
import { getJobModel } from '../../util/jobs';
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
import { WorkflowOutput } from './WorkflowOutput';
|
import { WorkflowOutput } from './WorkflowOutput';
|
||||||
import useWsJob from './useWsJob';
|
import useWsJob from './useWsJob';
|
||||||
|
|
||||||
|
// maps the displayed url segments to actual api types
|
||||||
|
export const JOB_URL_SEGMENT_MAP = {
|
||||||
|
playbook: 'job',
|
||||||
|
project: 'project_update',
|
||||||
|
management: 'system_job',
|
||||||
|
inventory: 'inventory_update',
|
||||||
|
command: 'ad_hoc_command',
|
||||||
|
workflow: 'workflow_job',
|
||||||
|
};
|
||||||
|
|
||||||
function Job({ i18n, setBreadcrumb }) {
|
function Job({ i18n, setBreadcrumb }) {
|
||||||
const { id, type } = useParams();
|
const { id, typeSegment } = useParams();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
|
||||||
|
const type = JOB_URL_SEGMENT_MAP[typeSegment];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -34,12 +46,11 @@ function Job({ i18n, setBreadcrumb }) {
|
|||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
let eventOptions = {};
|
let eventOptions = {};
|
||||||
const { data: jobDetailData } = await JobsAPI.readDetail(id, type);
|
const { data: jobDetailData } = await getJobModel(type).readDetail(id);
|
||||||
if (jobDetailData.type !== 'workflow_job') {
|
if (type !== 'workflow_job') {
|
||||||
const { data: jobEventOptions } = await JobsAPI.readEventOptions(
|
const { data: jobEventOptions } = await getJobModel(
|
||||||
id,
|
|
||||||
type
|
type
|
||||||
);
|
).readEventOptions(id);
|
||||||
eventOptions = jobEventOptions;
|
eventOptions = jobEventOptions;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -49,7 +60,7 @@ function Job({ i18n, setBreadcrumb }) {
|
|||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
data: { results },
|
data: { results },
|
||||||
} = await JobsAPI.readCredentials(jobDetailData.id, type);
|
} = await getJobModel(type).readCredentials(jobDetailData.id);
|
||||||
|
|
||||||
jobDetailData.summary_fields.credentials = results;
|
jobDetailData.summary_fields.credentials = results;
|
||||||
}
|
}
|
||||||
@@ -125,37 +136,37 @@ function Job({ i18n, setBreadcrumb }) {
|
|||||||
<Card>
|
<Card>
|
||||||
<RoutedTabs tabsArray={tabsArray} />
|
<RoutedTabs tabsArray={tabsArray} />
|
||||||
<Switch>
|
<Switch>
|
||||||
<Redirect from="/jobs/:type/:id" to="/jobs/:type/:id/output" exact />
|
<Redirect
|
||||||
{job &&
|
from="/jobs/:typeSegment/:id"
|
||||||
job.type === 'workflow_job' && [
|
to="/jobs/:typeSegment/:id/output"
|
||||||
<Route key="workflow-details" path="/jobs/workflow/:id/details">
|
exact
|
||||||
<JobDetail type={match.params.type} job={job} />
|
/>
|
||||||
</Route>,
|
{job && [
|
||||||
<Route key="workflow-output" path="/jobs/workflow/:id/output">
|
<Route
|
||||||
|
key={job.type === 'workflow_job' ? 'workflow-details' : 'details'}
|
||||||
|
path="/jobs/:typeSegment/:id/details"
|
||||||
|
>
|
||||||
|
<JobDetail job={job} />
|
||||||
|
</Route>,
|
||||||
|
<Route key="output" path="/jobs/:typeSegment/:id/output">
|
||||||
|
{job.type === 'workflow_job' ? (
|
||||||
<WorkflowOutput job={job} />
|
<WorkflowOutput job={job} />
|
||||||
</Route>,
|
) : (
|
||||||
]}
|
|
||||||
{job &&
|
|
||||||
job.type !== 'workflow_job' && [
|
|
||||||
<Route key="details" path="/jobs/:type/:id/details">
|
|
||||||
<JobDetail type={type} job={job} />
|
|
||||||
</Route>,
|
|
||||||
<Route key="output" path="/jobs/:type/:id/output">
|
|
||||||
<JobOutput
|
<JobOutput
|
||||||
type={type}
|
|
||||||
job={job}
|
job={job}
|
||||||
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
|
||||||
eventSearchableKeys={eventSearchableKeys}
|
eventSearchableKeys={eventSearchableKeys}
|
||||||
/>
|
/>
|
||||||
</Route>,
|
)}
|
||||||
<Route key="not-found" path="*">
|
</Route>,
|
||||||
<ContentError isNotFound>
|
<Route key="not-found" path="*">
|
||||||
<Link to={`/jobs/${type}/${id}/details`}>
|
<ContentError isNotFound>
|
||||||
{i18n._(t`View Job Details`)}
|
<Link to={`/jobs/${typeSegment}/${id}/details`}>
|
||||||
</Link>
|
{i18n._(t`View Job Details`)}
|
||||||
</ContentError>
|
</Link>
|
||||||
</Route>,
|
</ContentError>
|
||||||
]}
|
</Route>,
|
||||||
|
]}
|
||||||
</Switch>
|
</Switch>
|
||||||
</Card>
|
</Card>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
@@ -25,17 +25,11 @@ import {
|
|||||||
} from '../../../components/LaunchButton';
|
} from '../../../components/LaunchButton';
|
||||||
import StatusIcon from '../../../components/StatusIcon';
|
import StatusIcon from '../../../components/StatusIcon';
|
||||||
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
|
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
|
||||||
|
import { getJobModel, isJobRunning } from '../../../util/jobs';
|
||||||
import { toTitleCase } from '../../../util/strings';
|
import { toTitleCase } from '../../../util/strings';
|
||||||
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { formatDateString } from '../../../util/dates';
|
import { formatDateString } from '../../../util/dates';
|
||||||
import { Job } from '../../../types';
|
import { Job } from '../../../types';
|
||||||
import {
|
|
||||||
JobsAPI,
|
|
||||||
ProjectUpdatesAPI,
|
|
||||||
SystemJobsAPI,
|
|
||||||
WorkflowJobsAPI,
|
|
||||||
InventoriesAPI,
|
|
||||||
AdHocCommandsAPI,
|
|
||||||
} from '../../../api';
|
|
||||||
|
|
||||||
const VariablesInput = styled(_VariablesInput)`
|
const VariablesInput = styled(_VariablesInput)`
|
||||||
.pf-c-form__label {
|
.pf-c-form__label {
|
||||||
@@ -77,6 +71,24 @@ function JobDetail({ job, i18n }) {
|
|||||||
const [errorMsg, setErrorMsg] = useState();
|
const [errorMsg, setErrorMsg] = useState();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: cancelError,
|
||||||
|
isLoading: isCancelling,
|
||||||
|
request: cancelJob,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
await getJobModel(job.type).cancel(job.id, job.type);
|
||||||
|
}, [job.id, job.type]),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
error: dismissableCancelError,
|
||||||
|
dismissError: dismissCancelError,
|
||||||
|
} = useDismissableError(cancelError);
|
||||||
|
|
||||||
const jobTypes = {
|
const jobTypes = {
|
||||||
project_update: i18n._(t`Source Control Update`),
|
project_update: i18n._(t`Source Control Update`),
|
||||||
inventory_update: i18n._(t`Inventory Sync`),
|
inventory_update: i18n._(t`Inventory Sync`),
|
||||||
@@ -91,25 +103,7 @@ function JobDetail({ job, i18n }) {
|
|||||||
|
|
||||||
const deleteJob = async () => {
|
const deleteJob = async () => {
|
||||||
try {
|
try {
|
||||||
switch (job.type) {
|
await getJobModel(job.type).destroy(job.id);
|
||||||
case 'project_update':
|
|
||||||
await ProjectUpdatesAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'system_job':
|
|
||||||
await SystemJobsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'workflow_job':
|
|
||||||
await WorkflowJobsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'ad_hoc_command':
|
|
||||||
await AdHocCommandsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'inventory_update':
|
|
||||||
await InventoriesAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
await JobsAPI.destroy(job.id);
|
|
||||||
}
|
|
||||||
history.push('/jobs');
|
history.push('/jobs');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrorMsg(err);
|
setErrorMsg(err);
|
||||||
@@ -410,16 +404,75 @@ function JobDetail({ job, i18n }) {
|
|||||||
)}
|
)}
|
||||||
</LaunchButton>
|
</LaunchButton>
|
||||||
))}
|
))}
|
||||||
{job.summary_fields.user_capabilities.delete && (
|
{isJobRunning(job.status) &&
|
||||||
<DeleteButton
|
job?.summary_fields?.user_capabilities?.start && (
|
||||||
name={job.name}
|
<Button
|
||||||
modalTitle={i18n._(t`Delete Job`)}
|
variant="secondary"
|
||||||
onConfirm={deleteJob}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
>
|
isDisabled={isCancelling}
|
||||||
{i18n._(t`Delete`)}
|
onClick={() => setShowCancelModal(true)}
|
||||||
</DeleteButton>
|
ouiaId="job-detail-cancel-button"
|
||||||
)}
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isJobRunning(job.status) &&
|
||||||
|
job?.summary_fields?.user_capabilities?.delete && (
|
||||||
|
<DeleteButton
|
||||||
|
name={job.name}
|
||||||
|
modalTitle={i18n._(t`Delete Job`)}
|
||||||
|
onConfirm={deleteJob}
|
||||||
|
ouiaId="job-detail-delete-button"
|
||||||
|
>
|
||||||
|
{i18n._(t`Delete`)}
|
||||||
|
</DeleteButton>
|
||||||
|
)}
|
||||||
</CardActionsRow>
|
</CardActionsRow>
|
||||||
|
{showCancelModal && isJobRunning(job.status) && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={showCancelModal}
|
||||||
|
variant="danger"
|
||||||
|
onClose={() => setShowCancelModal(false)}
|
||||||
|
title={i18n._(t`Cancel Job`)}
|
||||||
|
label={i18n._(t`Cancel Job`)}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="cancel-job-confirm-button"
|
||||||
|
key="delete"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={isCancelling}
|
||||||
|
aria-label={i18n._(t`Cancel job`)}
|
||||||
|
onClick={cancelJob}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel job`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="cancel-job-return-button"
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Return`)}
|
||||||
|
onClick={() => setShowCancelModal(false)}
|
||||||
|
>
|
||||||
|
{i18n._(t`Return`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{i18n._(
|
||||||
|
t`Are you sure you want to submit the request to cancel this job?`
|
||||||
|
)}
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
{dismissableCancelError && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={dismissableCancelError}
|
||||||
|
variant="danger"
|
||||||
|
onClose={dismissCancelError}
|
||||||
|
title={i18n._(t`Job Cancel Error`)}
|
||||||
|
label={i18n._(t`Job Cancel Error`)}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={dismissableCancelError} />
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
{errorMsg && (
|
{errorMsg && (
|
||||||
<AlertModal
|
<AlertModal
|
||||||
isOpen={errorMsg}
|
isOpen={errorMsg}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe('<JobDetail />', () => {
|
|||||||
wrapper.update();
|
wrapper.update();
|
||||||
const modal = wrapper.find('Modal');
|
const modal = wrapper.find('Modal');
|
||||||
expect(modal.length).toBe(1);
|
expect(modal.length).toBe(1);
|
||||||
modal.find('button[aria-label="Delete"]').simulate('click');
|
modal.find('button[aria-label="Confirm Delete"]').simulate('click');
|
||||||
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ describe('<JobDetail />', () => {
|
|||||||
const modal = wrapper.find('Modal');
|
const modal = wrapper.find('Modal');
|
||||||
expect(modal.length).toBe(1);
|
expect(modal.length).toBe(1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
modal.find('button[aria-label="Delete"]').simulate('click');
|
modal.find('button[aria-label="Confirm Delete"]').simulate('click');
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import PageControls from './PageControls';
|
|||||||
import HostEventModal from './HostEventModal';
|
import HostEventModal from './HostEventModal';
|
||||||
import { HostStatusBar, OutputToolbar } from './shared';
|
import { HostStatusBar, OutputToolbar } from './shared';
|
||||||
import getRowRangePageSize from './shared/jobOutputUtils';
|
import getRowRangePageSize from './shared/jobOutputUtils';
|
||||||
import isJobRunning from '../../../util/jobs';
|
import { getJobModel, isJobRunning } from '../../../util/jobs';
|
||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import {
|
import {
|
||||||
encodeNonDefaultQueryString,
|
encodeNonDefaultQueryString,
|
||||||
@@ -47,14 +47,6 @@ import {
|
|||||||
removeParams,
|
removeParams,
|
||||||
getQSConfig,
|
getQSConfig,
|
||||||
} from '../../../util/qs';
|
} from '../../../util/qs';
|
||||||
import {
|
|
||||||
JobsAPI,
|
|
||||||
ProjectUpdatesAPI,
|
|
||||||
SystemJobsAPI,
|
|
||||||
WorkflowJobsAPI,
|
|
||||||
InventoriesAPI,
|
|
||||||
AdHocCommandsAPI,
|
|
||||||
} from '../../../api';
|
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('job_output', {
|
const QS_CONFIG = getQSConfig('job_output', {
|
||||||
order_by: 'start_line',
|
order_by: 'start_line',
|
||||||
@@ -280,12 +272,7 @@ const cache = new CellMeasurerCache({
|
|||||||
defaultHeight: 25,
|
defaultHeight: 25,
|
||||||
});
|
});
|
||||||
|
|
||||||
function JobOutput({
|
function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
|
||||||
job,
|
|
||||||
type,
|
|
||||||
eventRelatedSearchableKeys,
|
|
||||||
eventSearchableKeys,
|
|
||||||
}) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const listRef = useRef(null);
|
const listRef = useRef(null);
|
||||||
const isMounted = useRef(false);
|
const isMounted = useRef(false);
|
||||||
@@ -348,8 +335,8 @@ function JobOutput({
|
|||||||
request: cancelJob,
|
request: cancelJob,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
await JobsAPI.cancel(job.id, type);
|
await getJobModel(job.type).cancel(job.id);
|
||||||
}, [job.id, type]),
|
}, [job.id, job.type]),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -364,27 +351,10 @@ function JobOutput({
|
|||||||
error: deleteError,
|
error: deleteError,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
switch (job.type) {
|
await getJobModel(job.type).destroy(job.id);
|
||||||
case 'project_update':
|
|
||||||
await ProjectUpdatesAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'system_job':
|
|
||||||
await SystemJobsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'workflow_job':
|
|
||||||
await WorkflowJobsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'ad_hoc_command':
|
|
||||||
await AdHocCommandsAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
case 'inventory_update':
|
|
||||||
await InventoriesAPI.destroy(job.id);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
await JobsAPI.destroy(job.id);
|
|
||||||
}
|
|
||||||
history.push('/jobs');
|
history.push('/jobs');
|
||||||
}, [job, history])
|
}, [job.type, job.id, history])
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -417,7 +387,7 @@ function JobOutput({
|
|||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
data: { results: fetchedEvents = [], count },
|
data: { results: fetchedEvents = [], count },
|
||||||
} = await JobsAPI.readEvents(job.id, type, {
|
} = await getJobModel(job.type).readEvents(job.id, {
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
...parseQueryString(QS_CONFIG, location.search),
|
...parseQueryString(QS_CONFIG, location.search),
|
||||||
@@ -557,31 +527,33 @@ function JobOutput({
|
|||||||
...parseQueryString(QS_CONFIG, location.search),
|
...parseQueryString(QS_CONFIG, location.search),
|
||||||
};
|
};
|
||||||
|
|
||||||
return JobsAPI.readEvents(job.id, type, params).then(response => {
|
return getJobModel(job.type)
|
||||||
if (isMounted.current) {
|
.readEvents(job.id, params)
|
||||||
const newResults = {};
|
.then(response => {
|
||||||
let newResultsCssMap = {};
|
if (isMounted.current) {
|
||||||
response.data.results.forEach((jobEvent, index) => {
|
const newResults = {};
|
||||||
newResults[firstIndex + index] = jobEvent;
|
let newResultsCssMap = {};
|
||||||
const { lineCssMap } = getLineTextHtml(jobEvent);
|
response.data.results.forEach((jobEvent, index) => {
|
||||||
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
|
newResults[firstIndex + index] = jobEvent;
|
||||||
});
|
const { lineCssMap } = getLineTextHtml(jobEvent);
|
||||||
setResults(prevResults => ({
|
newResultsCssMap = { ...newResultsCssMap, ...lineCssMap };
|
||||||
...prevResults,
|
});
|
||||||
...newResults,
|
setResults(prevResults => ({
|
||||||
}));
|
...prevResults,
|
||||||
setCssMap(prevCssMap => ({
|
...newResults,
|
||||||
...prevCssMap,
|
}));
|
||||||
...newResultsCssMap,
|
setCssMap(prevCssMap => ({
|
||||||
}));
|
...prevCssMap,
|
||||||
setCurrentlyLoading(prevCurrentlyLoading =>
|
...newResultsCssMap,
|
||||||
prevCurrentlyLoading.filter(n => !loadRange.includes(n))
|
}));
|
||||||
);
|
setCurrentlyLoading(prevCurrentlyLoading =>
|
||||||
loadRange.forEach(n => {
|
prevCurrentlyLoading.filter(n => !loadRange.includes(n))
|
||||||
cache.clear(n);
|
);
|
||||||
});
|
loadRange.forEach(n => {
|
||||||
}
|
cache.clear(n);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToRow = rowIndex => {
|
const scrollToRow = rowIndex => {
|
||||||
|
|||||||
@@ -188,9 +188,19 @@ describe('<JobOutput />', () => {
|
|||||||
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
wrapper = mountWithContexts(<JobOutput job={mockJob} />);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
await waitForElement(wrapper, 'JobEvent', el => el.length > 0);
|
||||||
await act(async () => {
|
await act(async () =>
|
||||||
wrapper.find('DeleteButton').invoke('onConfirm')();
|
wrapper.find('button[aria-label="Delete"]').simulate('click')
|
||||||
});
|
);
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'Modal',
|
||||||
|
el => el.props().isOpen === true && el.props().title === 'Delete Job'
|
||||||
|
);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper
|
||||||
|
.find('Modal button[aria-label="Confirm Delete"]')
|
||||||
|
.simulate('click')
|
||||||
|
);
|
||||||
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +278,7 @@ describe('<JobOutput />', () => {
|
|||||||
wrapper.find(searchBtn).simulate('click');
|
wrapper.find(searchBtn).simulate('click');
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, undefined, {
|
expect(JobsAPI.readEvents).toHaveBeenCalledWith(2, {
|
||||||
order_by: 'start_line',
|
order_by: 'start_line',
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 50,
|
page_size: 50,
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ function JobTypeRedirect({ id, path, view, i18n }) {
|
|||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||||
return <Redirect from={path} to={`/jobs/${type}/${job.id}/${view}`} />;
|
return <Redirect from={path} to={`/jobs/${typeSegment}/${job.id}/${view}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
JobTypeRedirect.defaultProps = {
|
JobTypeRedirect.defaultProps = {
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ function Jobs({ i18n }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = JOB_TYPE_URL_SEGMENTS[job.type];
|
const typeSegment = JOB_TYPE_URL_SEGMENTS[job.type];
|
||||||
setBreadcrumbConfig({
|
setBreadcrumbConfig({
|
||||||
'/jobs': i18n._(t`Jobs`),
|
'/jobs': i18n._(t`Jobs`),
|
||||||
[`/jobs/${type}/${job.id}`]: `${job.name}`,
|
[`/jobs/${typeSegment}/${job.id}`]: `${job.name}`,
|
||||||
[`/jobs/${type}/${job.id}/output`]: i18n._(t`Output`),
|
[`/jobs/${typeSegment}/${job.id}/output`]: i18n._(t`Output`),
|
||||||
[`/jobs/${type}/${job.id}/details`]: i18n._(t`Details`),
|
[`/jobs/${typeSegment}/${job.id}/details`]: i18n._(t`Details`),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[i18n]
|
[i18n]
|
||||||
@@ -53,7 +53,7 @@ function Jobs({ i18n }) {
|
|||||||
<Route path={`${match.path}/:id/output`}>
|
<Route path={`${match.path}/:id/output`}>
|
||||||
<TypeRedirect view="output" />
|
<TypeRedirect view="output" />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={`${match.path}/:type/:id`}>
|
<Route path={`${match.path}/:typeSegment/:id`}>
|
||||||
<Job setBreadcrumb={buildBreadcrumbConfig} />
|
<Job setBreadcrumb={buildBreadcrumbConfig} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={`${match.path}/:id`}>
|
<Route path={`${match.path}/:id`}>
|
||||||
|
|||||||
@@ -154,7 +154,6 @@
|
|||||||
"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": "/var/lib/awx/venv/ansible",
|
"ANSIBLE_VENV_PATH": "/var/lib/awx/venv/ansible",
|
||||||
"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": "/var/lib/awx/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:",
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import useWebsocket from '../../util/useWebsocket';
|
import useWebsocket from '../../util/useWebsocket';
|
||||||
import { JobsAPI } from '../../api';
|
import { getJobModel } from '../../util/jobs';
|
||||||
|
|
||||||
export default function useWsJob(initialJob) {
|
export default function useWsJob(initialJob) {
|
||||||
const { type } = useParams();
|
|
||||||
const [job, setJob] = useState(initialJob);
|
const [job, setJob] = useState(initialJob);
|
||||||
const lastMessage = useWebsocket({
|
const lastMessage = useWebsocket({
|
||||||
jobs: ['status_changed'],
|
jobs: ['status_changed'],
|
||||||
@@ -18,7 +16,7 @@ export default function useWsJob(initialJob) {
|
|||||||
useEffect(
|
useEffect(
|
||||||
function parseWsMessage() {
|
function parseWsMessage() {
|
||||||
async function fetchJob() {
|
async function fetchJob() {
|
||||||
const { data } = await JobsAPI.readDetail(job.id, type);
|
const { data } = await getJobModel(job.type).readDetail(job.id);
|
||||||
setJob(data);
|
setJob(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import ErrorDetail from '../../../components/ErrorDetail';
|
|||||||
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
import useRequest, { useDismissableError } from '../../../util/useRequest';
|
||||||
import { useConfig } from '../../../contexts/Config';
|
import { useConfig } from '../../../contexts/Config';
|
||||||
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
|
import ExecutionEnvironmentDetail from '../../../components/ExecutionEnvironmentDetail';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
function OrganizationDetail({ i18n, organization }) {
|
function OrganizationDetail({ i18n, organization }) {
|
||||||
const {
|
const {
|
||||||
@@ -71,6 +72,11 @@ function OrganizationDetail({ i18n, organization }) {
|
|||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.organization(
|
||||||
|
organization,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
@@ -157,6 +163,10 @@ function OrganizationDetail({ i18n, organization }) {
|
|||||||
modalTitle={i18n._(t`Delete Organization`)}
|
modalTitle={i18n._(t`Delete Organization`)}
|
||||||
onConfirm={deleteOrganization}
|
onConfirm={deleteOrganization}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This organization is currently being by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
import { OrganizationsAPI } from '../../../api';
|
import { OrganizationsAPI, CredentialsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -44,6 +44,8 @@ describe('<OrganizationDetail />', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
|
|
||||||
OrganizationsAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
|
OrganizationsAPI.readInstanceGroups.mockResolvedValue(mockInstanceGroups);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +66,20 @@ describe('<OrganizationDetail />', () => {
|
|||||||
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
|
expect(OrganizationsAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
let component;
|
||||||
|
await act(async () => {
|
||||||
|
component = mountWithContexts(
|
||||||
|
<OrganizationDetail organization={mockOrganization} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(component, 'ContentLoading', el => el.length === 0);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.find('DeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
test('should render the expected instance group', async () => {
|
test('should render the expected instance group', async () => {
|
||||||
let component;
|
let component;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import PaginatedTable, {
|
|||||||
} from '../../../components/PaginatedTable';
|
} from '../../../components/PaginatedTable';
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||||
import OrganizationListItem from './OrganizationListItem';
|
import OrganizationListItem from './OrganizationListItem';
|
||||||
|
import { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig('organization', {
|
const QS_CONFIG = getQSConfig('organization', {
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -116,6 +117,10 @@ function OrganizationsList({ i18n }) {
|
|||||||
setSelected(selected.concat(row));
|
setSelected(selected.concat(row));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.organization(
|
||||||
|
selected[0],
|
||||||
|
i18n
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -173,6 +178,11 @@ function OrganizationsList({ i18n }) {
|
|||||||
onDelete={handleOrgDelete}
|
onDelete={handleOrgDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName={i18n._(t`Organizations`)}
|
pluralizedItemName={i18n._(t`Organizations`)}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
'{numItemsToDelete, plural, one {This organization is currently being by other resources. Are you sure you want to delete it?} other {Deleting these organizations could impact other resources that rely on them. Are you sure you want to delete anyway?}}',
|
||||||
|
{ numItemsToDelete: selected.length }
|
||||||
|
)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
import { OrganizationsAPI } from '../../../api';
|
import { OrganizationsAPI, CredentialsAPI } from '../../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -70,6 +70,7 @@ const mockOrganizations = {
|
|||||||
describe('<OrganizationsList />', () => {
|
describe('<OrganizationsList />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
CredentialsAPI.read.mockResolvedValue({ data: { count: 0 } });
|
||||||
OrganizationsAPI.read.mockResolvedValue(mockOrganizations);
|
OrganizationsAPI.read.mockResolvedValue(mockOrganizations);
|
||||||
OrganizationsAPI.readOptions.mockResolvedValue({
|
OrganizationsAPI.readOptions.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
@@ -90,6 +91,20 @@ describe('<OrganizationsList />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should have proper number of delete detail requests', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(<OrganizationsList />);
|
||||||
|
});
|
||||||
|
await waitForElement(
|
||||||
|
wrapper,
|
||||||
|
'OrganizationsList',
|
||||||
|
el => el.find('ContentLoading').length === 0
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
wrapper.find('ToolbarDeleteButton').prop('deleteDetailsRequests')
|
||||||
|
).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
test('Items are rendered after loading', async () => {
|
test('Items are rendered after loading', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(<OrganizationsList />);
|
wrapper = mountWithContexts(<OrganizationsList />);
|
||||||
|
|||||||
@@ -20,6 +20,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 { relatedResourceDeleteRequests } from '../../../util/getRelatedResourceDeleteDetails';
|
||||||
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
import ProjectSyncButton from '../shared/ProjectSyncButton';
|
||||||
|
|
||||||
function ProjectDetail({ project, i18n }) {
|
function ProjectDetail({ project, i18n }) {
|
||||||
@@ -52,7 +53,10 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { error, dismissError } = useDismissableError(deleteError);
|
const { error, dismissError } = useDismissableError(deleteError);
|
||||||
|
const deleteDetailsRequests = relatedResourceDeleteRequests.project(
|
||||||
|
project,
|
||||||
|
i18n
|
||||||
|
);
|
||||||
let optionsList = '';
|
let optionsList = '';
|
||||||
if (
|
if (
|
||||||
scm_clean ||
|
scm_clean ||
|
||||||
@@ -171,6 +175,10 @@ function ProjectDetail({ project, i18n }) {
|
|||||||
modalTitle={i18n._(t`Delete Project`)}
|
modalTitle={i18n._(t`Delete Project`)}
|
||||||
onConfirm={deleteProject}
|
onConfirm={deleteProject}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
|
deleteDetailsRequests={deleteDetailsRequests}
|
||||||
|
deleteMessage={i18n._(
|
||||||
|
t`This project is currently being used by other resources. Are you sure you want to delete it?`
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</DeleteButton>
|
</DeleteButton>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user