mirror of
https://github.com/ansible/awx.git
synced 2026-03-09 13:39:27 -02:30
Merge branch 'fix-awx_collection-docs' of github.com:sean-m-sullivan/awx into fix-awx_collection-docs
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
[](https://ansible.softwarefactory-project.io/zuul/status)
|
[](https://ansible.softwarefactory-project.io/zuul/status)
|
||||||
|
|
||||||
|
<img src="https://raw.githubusercontent.com/ansible/awx-logos/master/awx/ui/client/assets/logo-login.svg?sanitize=true" width=200 alt="AWX" />
|
||||||
|
|
||||||
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
|
AWX provides a web-based user interface, REST API, and task engine built on top of [Ansible](https://github.com/ansible/ansible). It is the upstream project for [Tower](https://www.ansible.com/tower), a commercial derivative of AWX.
|
||||||
|
|
||||||
To install AWX, please view the [Install guide](./INSTALL.md).
|
To install AWX, please view the [Install guide](./INSTALL.md).
|
||||||
|
|||||||
@@ -828,6 +828,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
return self.inventory.hosts.only(*only)
|
return self.inventory.hosts.only(*only)
|
||||||
|
|
||||||
def start_job_fact_cache(self, destination, modification_times, timeout=None):
|
def start_job_fact_cache(self, destination, modification_times, timeout=None):
|
||||||
|
self.log_lifecycle("start_job_fact_cache")
|
||||||
os.makedirs(destination, mode=0o700)
|
os.makedirs(destination, mode=0o700)
|
||||||
hosts = self._get_inventory_hosts()
|
hosts = self._get_inventory_hosts()
|
||||||
if timeout is None:
|
if timeout is None:
|
||||||
@@ -852,6 +853,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
|||||||
modification_times[filepath] = os.path.getmtime(filepath)
|
modification_times[filepath] = os.path.getmtime(filepath)
|
||||||
|
|
||||||
def finish_job_fact_cache(self, destination, modification_times):
|
def finish_job_fact_cache(self, destination, modification_times):
|
||||||
|
self.log_lifecycle("finish_job_fact_cache")
|
||||||
for host in self._get_inventory_hosts():
|
for host in self._get_inventory_hosts():
|
||||||
filepath = os.sep.join(map(str, [destination, host.name]))
|
filepath = os.sep.join(map(str, [destination, host.name]))
|
||||||
if not os.path.realpath(filepath).startswith(destination):
|
if not os.path.realpath(filepath).startswith(destination):
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ class JobNotificationMixin(object):
|
|||||||
{'unified_job_template': ['id', 'name', 'description', 'unified_job_type']},
|
{'unified_job_template': ['id', 'name', 'description', 'unified_job_type']},
|
||||||
{'instance_group': ['name', 'id']},
|
{'instance_group': ['name', 'id']},
|
||||||
{'created_by': ['id', 'username', 'first_name', 'last_name']},
|
{'created_by': ['id', 'username', 'first_name', 'last_name']},
|
||||||
|
{'schedule': ['id', 'name', 'description', 'next_run']},
|
||||||
{'labels': ['count', 'results']}]}]
|
{'labels': ['count', 'results']}]}]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -344,6 +345,10 @@ class JobNotificationMixin(object):
|
|||||||
'name': 'Stub project',
|
'name': 'Stub project',
|
||||||
'scm_type': 'git',
|
'scm_type': 'git',
|
||||||
'status': 'successful'},
|
'status': 'successful'},
|
||||||
|
'schedule': {'description': 'Sample schedule',
|
||||||
|
'id': 42,
|
||||||
|
'name': 'Stub schedule',
|
||||||
|
'next_run': datetime.datetime(2038, 1, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc)},
|
||||||
'unified_job_template': {'description': 'Sample unified job template description',
|
'unified_job_template': {'description': 'Sample unified job template description',
|
||||||
'id': 39,
|
'id': 39,
|
||||||
'name': 'Stub Job Template',
|
'name': 'Stub Job Template',
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ from awx.main.fields import JSONField, AskForField, OrderedManyToManyField
|
|||||||
__all__ = ['UnifiedJobTemplate', 'UnifiedJob', 'StdoutMaxBytesExceeded']
|
__all__ = ['UnifiedJobTemplate', 'UnifiedJob', 'StdoutMaxBytesExceeded']
|
||||||
|
|
||||||
logger = logging.getLogger('awx.main.models.unified_jobs')
|
logger = logging.getLogger('awx.main.models.unified_jobs')
|
||||||
|
logger_job_lifecycle = logging.getLogger('awx.analytics.job_lifecycle')
|
||||||
# NOTE: ACTIVE_STATES moved to constants because it is used by parent modules
|
# NOTE: ACTIVE_STATES moved to constants because it is used by parent modules
|
||||||
|
|
||||||
|
|
||||||
@@ -420,7 +420,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
|
|||||||
# have been associated to the UJ
|
# have been associated to the UJ
|
||||||
if unified_job.__class__ in activity_stream_registrar.models:
|
if unified_job.__class__ in activity_stream_registrar.models:
|
||||||
activity_stream_create(None, unified_job, True)
|
activity_stream_create(None, unified_job, True)
|
||||||
|
unified_job.log_lifecycle("created")
|
||||||
return unified_job
|
return unified_job
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -862,7 +862,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
self.unified_job_template = self._get_parent_instance()
|
self.unified_job_template = self._get_parent_instance()
|
||||||
if 'unified_job_template' not in update_fields:
|
if 'unified_job_template' not in update_fields:
|
||||||
update_fields.append('unified_job_template')
|
update_fields.append('unified_job_template')
|
||||||
|
|
||||||
if self.cancel_flag and not self.canceled_on:
|
if self.cancel_flag and not self.canceled_on:
|
||||||
# Record the 'canceled' time.
|
# Record the 'canceled' time.
|
||||||
self.canceled_on = now()
|
self.canceled_on = now()
|
||||||
@@ -1010,6 +1010,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
event_qs = self.get_event_queryset()
|
event_qs = self.get_event_queryset()
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return True # Model without events, such as WFJT
|
return True # Model without events, such as WFJT
|
||||||
|
self.log_lifecycle("event_processing_finished")
|
||||||
return self.emitted_events == event_qs.count()
|
return self.emitted_events == event_qs.count()
|
||||||
|
|
||||||
def result_stdout_raw_handle(self, enforce_max_bytes=True):
|
def result_stdout_raw_handle(self, enforce_max_bytes=True):
|
||||||
@@ -1318,6 +1319,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
if 'extra_vars' in kwargs:
|
if 'extra_vars' in kwargs:
|
||||||
self.handle_extra_data(kwargs['extra_vars'])
|
self.handle_extra_data(kwargs['extra_vars'])
|
||||||
|
|
||||||
|
# remove any job_explanations that may have been set while job was in pending
|
||||||
|
if self.job_explanation != "":
|
||||||
|
self.job_explanation = ""
|
||||||
|
|
||||||
return (True, opts)
|
return (True, opts)
|
||||||
|
|
||||||
def signal_start(self, **kwargs):
|
def signal_start(self, **kwargs):
|
||||||
@@ -1484,3 +1489,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
@property
|
@property
|
||||||
def is_containerized(self):
|
def is_containerized(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def log_lifecycle(self, state, blocked_by=None):
|
||||||
|
extra={'type': self._meta.model_name,
|
||||||
|
'task_id': self.id,
|
||||||
|
'state': state}
|
||||||
|
if self.unified_job_template:
|
||||||
|
extra["template_name"] = self.unified_job_template.name
|
||||||
|
if state == "blocked" and blocked_by:
|
||||||
|
blocked_by_msg = f"{blocked_by._meta.model_name}-{blocked_by.id}"
|
||||||
|
msg = f"{self._meta.model_name}-{self.id} blocked by {blocked_by_msg}"
|
||||||
|
extra["blocked_by"] = blocked_by_msg
|
||||||
|
else:
|
||||||
|
msg = f"{self._meta.model_name}-{self.id} {state.replace('_', ' ')}"
|
||||||
|
logger_job_lifecycle.debug(msg, extra=extra)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from django.utils.timezone import now as tz_now
|
|
||||||
|
|
||||||
from awx.main.models import (
|
from awx.main.models import (
|
||||||
Job,
|
Job,
|
||||||
ProjectUpdate,
|
ProjectUpdate,
|
||||||
@@ -20,119 +18,110 @@ class DependencyGraph(object):
|
|||||||
INVENTORY_SOURCE_UPDATES = 'inventory_source_updates'
|
INVENTORY_SOURCE_UPDATES = 'inventory_source_updates'
|
||||||
WORKFLOW_JOB_TEMPLATES_JOBS = 'workflow_job_template_jobs'
|
WORKFLOW_JOB_TEMPLATES_JOBS = 'workflow_job_template_jobs'
|
||||||
|
|
||||||
LATEST_PROJECT_UPDATES = 'latest_project_updates'
|
|
||||||
LATEST_INVENTORY_UPDATES = 'latest_inventory_updates'
|
|
||||||
|
|
||||||
INVENTORY_SOURCES = 'inventory_source_ids'
|
INVENTORY_SOURCES = 'inventory_source_ids'
|
||||||
|
|
||||||
def __init__(self, queue):
|
def __init__(self):
|
||||||
self.queue = queue
|
|
||||||
self.data = {}
|
self.data = {}
|
||||||
# project_id -> True / False
|
|
||||||
self.data[self.PROJECT_UPDATES] = {}
|
self.data[self.PROJECT_UPDATES] = {}
|
||||||
# inventory_id -> True / False
|
# The reason for tracking both inventory and inventory sources:
|
||||||
|
# Consider InvA, which has two sources, InvSource1, InvSource2.
|
||||||
|
# JobB might depend on InvA, which launches two updates, one for each source.
|
||||||
|
# To determine if JobB can run, we can just check InvA, which is marked in
|
||||||
|
# INVENTORY_UPDATES, instead of having to check for both entries in
|
||||||
|
# INVENTORY_SOURCE_UPDATES.
|
||||||
self.data[self.INVENTORY_UPDATES] = {}
|
self.data[self.INVENTORY_UPDATES] = {}
|
||||||
# job_template_id -> True / False
|
|
||||||
self.data[self.JOB_TEMPLATE_JOBS] = {}
|
|
||||||
|
|
||||||
'''
|
|
||||||
Track runnable job related project and inventory to ensure updates
|
|
||||||
don't run while a job needing those resources is running.
|
|
||||||
'''
|
|
||||||
|
|
||||||
# inventory_source_id -> True / False
|
|
||||||
self.data[self.INVENTORY_SOURCE_UPDATES] = {}
|
self.data[self.INVENTORY_SOURCE_UPDATES] = {}
|
||||||
# True / False
|
self.data[self.JOB_TEMPLATE_JOBS] = {}
|
||||||
self.data[self.SYSTEM_JOB] = True
|
self.data[self.SYSTEM_JOB] = {}
|
||||||
# workflow_job_template_id -> True / False
|
|
||||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
|
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
|
||||||
|
|
||||||
# project_id -> latest ProjectUpdateLatestDict'
|
def mark_if_no_key(self, job_type, id, job):
|
||||||
self.data[self.LATEST_PROJECT_UPDATES] = {}
|
# only mark first occurrence of a task. If 10 of JobA are launched
|
||||||
# inventory_source_id -> latest InventoryUpdateLatestDict
|
# (concurrent disabled), the dependency graph should return that jobs
|
||||||
self.data[self.LATEST_INVENTORY_UPDATES] = {}
|
# 2 through 10 are blocked by job1
|
||||||
|
if id not in self.data[job_type]:
|
||||||
|
self.data[job_type][id] = job
|
||||||
|
|
||||||
# inventory_id -> [inventory_source_ids]
|
def get_item(self, job_type, id):
|
||||||
self.data[self.INVENTORY_SOURCES] = {}
|
return self.data[job_type].get(id, None)
|
||||||
|
|
||||||
def add_latest_project_update(self, job):
|
def mark_system_job(self, job):
|
||||||
self.data[self.LATEST_PROJECT_UPDATES][job.project_id] = job
|
# Don't track different types of system jobs, so that only one can run
|
||||||
|
# at a time. Therefore id in this case is just 'system_job'.
|
||||||
def get_now(self):
|
self.mark_if_no_key(self.SYSTEM_JOB, 'system_job', job)
|
||||||
return tz_now()
|
|
||||||
|
|
||||||
def mark_system_job(self):
|
|
||||||
self.data[self.SYSTEM_JOB] = False
|
|
||||||
|
|
||||||
def mark_project_update(self, job):
|
def mark_project_update(self, job):
|
||||||
self.data[self.PROJECT_UPDATES][job.project_id] = False
|
self.mark_if_no_key(self.PROJECT_UPDATES, job.project_id, job)
|
||||||
|
|
||||||
def mark_inventory_update(self, inventory_id):
|
def mark_inventory_update(self, job):
|
||||||
self.data[self.INVENTORY_UPDATES][inventory_id] = False
|
if type(job) is AdHocCommand:
|
||||||
|
self.mark_if_no_key(self.INVENTORY_UPDATES, job.inventory_id, job)
|
||||||
|
else:
|
||||||
|
self.mark_if_no_key(self.INVENTORY_UPDATES, job.inventory_source.inventory_id, job)
|
||||||
|
|
||||||
def mark_inventory_source_update(self, inventory_source_id):
|
def mark_inventory_source_update(self, job):
|
||||||
self.data[self.INVENTORY_SOURCE_UPDATES][inventory_source_id] = False
|
self.mark_if_no_key(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id, job)
|
||||||
|
|
||||||
def mark_job_template_job(self, job):
|
def mark_job_template_job(self, job):
|
||||||
self.data[self.JOB_TEMPLATE_JOBS][job.job_template_id] = False
|
self.mark_if_no_key(self.JOB_TEMPLATE_JOBS, job.job_template_id, job)
|
||||||
|
|
||||||
def mark_workflow_job(self, job):
|
def mark_workflow_job(self, job):
|
||||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS][job.workflow_job_template_id] = False
|
self.mark_if_no_key(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id, job)
|
||||||
|
|
||||||
def can_project_update_run(self, job):
|
def project_update_blocked_by(self, job):
|
||||||
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
return self.get_item(self.PROJECT_UPDATES, job.project_id)
|
||||||
|
|
||||||
def can_inventory_update_run(self, job):
|
def inventory_update_blocked_by(self, job):
|
||||||
return self.data[self.INVENTORY_SOURCE_UPDATES].get(job.inventory_source_id, True)
|
return self.get_item(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id)
|
||||||
|
|
||||||
def can_job_run(self, job):
|
def job_blocked_by(self, job):
|
||||||
if self.data[self.PROJECT_UPDATES].get(job.project_id, True) is True and \
|
project_block = self.get_item(self.PROJECT_UPDATES, job.project_id)
|
||||||
self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True) is True:
|
inventory_block = self.get_item(self.INVENTORY_UPDATES, job.inventory_id)
|
||||||
if job.allow_simultaneous is False:
|
if job.allow_simultaneous is False:
|
||||||
return self.data[self.JOB_TEMPLATE_JOBS].get(job.job_template_id, True)
|
job_block = self.get_item(self.JOB_TEMPLATE_JOBS, job.job_template_id)
|
||||||
else:
|
else:
|
||||||
return True
|
job_block = None
|
||||||
return False
|
return project_block or inventory_block or job_block
|
||||||
|
|
||||||
def can_workflow_job_run(self, job):
|
def workflow_job_blocked_by(self, job):
|
||||||
if job.allow_simultaneous:
|
if job.allow_simultaneous is False:
|
||||||
return True
|
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id)
|
||||||
return self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS].get(job.workflow_job_template_id, True)
|
return None
|
||||||
|
|
||||||
def can_system_job_run(self):
|
def system_job_blocked_by(self, job):
|
||||||
return self.data[self.SYSTEM_JOB]
|
return self.get_item(self.SYSTEM_JOB, 'system_job')
|
||||||
|
|
||||||
def can_ad_hoc_command_run(self, job):
|
def ad_hoc_command_blocked_by(self, job):
|
||||||
return self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True)
|
return self.get_item(self.INVENTORY_UPDATES, job.inventory_id)
|
||||||
|
|
||||||
def is_job_blocked(self, job):
|
def task_blocked_by(self, job):
|
||||||
if type(job) is ProjectUpdate:
|
if type(job) is ProjectUpdate:
|
||||||
return not self.can_project_update_run(job)
|
return self.project_update_blocked_by(job)
|
||||||
elif type(job) is InventoryUpdate:
|
elif type(job) is InventoryUpdate:
|
||||||
return not self.can_inventory_update_run(job)
|
return self.inventory_update_blocked_by(job)
|
||||||
elif type(job) is Job:
|
elif type(job) is Job:
|
||||||
return not self.can_job_run(job)
|
return self.job_blocked_by(job)
|
||||||
elif type(job) is SystemJob:
|
elif type(job) is SystemJob:
|
||||||
return not self.can_system_job_run()
|
return self.system_job_blocked_by(job)
|
||||||
elif type(job) is AdHocCommand:
|
elif type(job) is AdHocCommand:
|
||||||
return not self.can_ad_hoc_command_run(job)
|
return self.ad_hoc_command_blocked_by(job)
|
||||||
elif type(job) is WorkflowJob:
|
elif type(job) is WorkflowJob:
|
||||||
return not self.can_workflow_job_run(job)
|
return self.workflow_job_blocked_by(job)
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
if type(job) is ProjectUpdate:
|
if type(job) is ProjectUpdate:
|
||||||
self.mark_project_update(job)
|
self.mark_project_update(job)
|
||||||
elif type(job) is InventoryUpdate:
|
elif type(job) is InventoryUpdate:
|
||||||
self.mark_inventory_update(job.inventory_source.inventory_id)
|
self.mark_inventory_update(job)
|
||||||
self.mark_inventory_source_update(job.inventory_source_id)
|
self.mark_inventory_source_update(job)
|
||||||
elif type(job) is Job:
|
elif type(job) is Job:
|
||||||
self.mark_job_template_job(job)
|
self.mark_job_template_job(job)
|
||||||
elif type(job) is WorkflowJob:
|
elif type(job) is WorkflowJob:
|
||||||
self.mark_workflow_job(job)
|
self.mark_workflow_job(job)
|
||||||
elif type(job) is SystemJob:
|
elif type(job) is SystemJob:
|
||||||
self.mark_system_job()
|
self.mark_system_job(job)
|
||||||
elif type(job) is AdHocCommand:
|
elif type(job) is AdHocCommand:
|
||||||
self.mark_inventory_update(job.inventory_id)
|
self.mark_inventory_update(job)
|
||||||
|
|
||||||
def add_jobs(self, jobs):
|
def add_jobs(self, jobs):
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ class TaskManager():
|
|||||||
# will no longer be started and will be started on the next task manager cycle.
|
# will no longer be started and will be started on the next task manager cycle.
|
||||||
self.start_task_limit = settings.START_TASK_LIMIT
|
self.start_task_limit = settings.START_TASK_LIMIT
|
||||||
|
|
||||||
|
self.time_delta_job_explanation = timedelta(seconds=30)
|
||||||
|
|
||||||
def after_lock_init(self):
|
def after_lock_init(self):
|
||||||
'''
|
'''
|
||||||
Init AFTER we know this instance of the task manager will run because the lock is acquired.
|
Init AFTER we know this instance of the task manager will run because the lock is acquired.
|
||||||
@@ -80,7 +82,7 @@ class TaskManager():
|
|||||||
instances_by_hostname = {i.hostname: i for i in instances_partial}
|
instances_by_hostname = {i.hostname: i for i in instances_partial}
|
||||||
|
|
||||||
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
|
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
|
||||||
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
|
self.graph[rampart_group.name] = dict(graph=DependencyGraph(),
|
||||||
capacity_total=rampart_group.capacity,
|
capacity_total=rampart_group.capacity,
|
||||||
consumed_capacity=0,
|
consumed_capacity=0,
|
||||||
instances=[])
|
instances=[])
|
||||||
@@ -88,18 +90,21 @@ class TaskManager():
|
|||||||
if instance.hostname in instances_by_hostname:
|
if instance.hostname in instances_by_hostname:
|
||||||
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
|
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
|
||||||
|
|
||||||
def is_job_blocked(self, task):
|
def job_blocked_by(self, task):
|
||||||
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
|
||||||
# in the old task manager this was handled as a method on each task object outside of the graph and
|
# in the old task manager this was handled as a method on each task object outside of the graph and
|
||||||
# probably has the side effect of cutting down *a lot* of the logic from this task manager class
|
# probably has the side effect of cutting down *a lot* of the logic from this task manager class
|
||||||
for g in self.graph:
|
for g in self.graph:
|
||||||
if self.graph[g]['graph'].is_job_blocked(task):
|
blocked_by = self.graph[g]['graph'].task_blocked_by(task)
|
||||||
return True
|
if blocked_by:
|
||||||
|
return blocked_by
|
||||||
|
|
||||||
if not task.dependent_jobs_finished():
|
if not task.dependent_jobs_finished():
|
||||||
return True
|
blocked_by = task.dependent_jobs.first()
|
||||||
|
if blocked_by:
|
||||||
|
return blocked_by
|
||||||
|
|
||||||
return False
|
return None
|
||||||
|
|
||||||
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
|
def get_tasks(self, status_list=('pending', 'waiting', 'running')):
|
||||||
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
jobs = [j for j in Job.objects.filter(status__in=status_list).prefetch_related('instance_group')]
|
||||||
@@ -312,6 +317,7 @@ class TaskManager():
|
|||||||
with disable_activity_stream():
|
with disable_activity_stream():
|
||||||
task.celery_task_id = str(uuid.uuid4())
|
task.celery_task_id = str(uuid.uuid4())
|
||||||
task.save()
|
task.save()
|
||||||
|
task.log_lifecycle("waiting")
|
||||||
|
|
||||||
if rampart_group is not None:
|
if rampart_group is not None:
|
||||||
self.consume_capacity(task, rampart_group.name)
|
self.consume_capacity(task, rampart_group.name)
|
||||||
@@ -450,6 +456,7 @@ class TaskManager():
|
|||||||
def generate_dependencies(self, undeped_tasks):
|
def generate_dependencies(self, undeped_tasks):
|
||||||
created_dependencies = []
|
created_dependencies = []
|
||||||
for task in undeped_tasks:
|
for task in undeped_tasks:
|
||||||
|
task.log_lifecycle("acknowledged")
|
||||||
dependencies = []
|
dependencies = []
|
||||||
if not type(task) is Job:
|
if not type(task) is Job:
|
||||||
continue
|
continue
|
||||||
@@ -489,11 +496,18 @@ class TaskManager():
|
|||||||
|
|
||||||
def process_pending_tasks(self, pending_tasks):
|
def process_pending_tasks(self, pending_tasks):
|
||||||
running_workflow_templates = set([wf.unified_job_template_id for wf in self.get_running_workflow_jobs()])
|
running_workflow_templates = set([wf.unified_job_template_id for wf in self.get_running_workflow_jobs()])
|
||||||
|
tasks_to_update_job_explanation = []
|
||||||
for task in pending_tasks:
|
for task in pending_tasks:
|
||||||
if self.start_task_limit <= 0:
|
if self.start_task_limit <= 0:
|
||||||
break
|
break
|
||||||
if self.is_job_blocked(task):
|
blocked_by = self.job_blocked_by(task)
|
||||||
logger.debug("{} is blocked from running".format(task.log_format))
|
if blocked_by:
|
||||||
|
task.log_lifecycle("blocked", blocked_by=blocked_by)
|
||||||
|
job_explanation = gettext_noop(f"waiting for {blocked_by._meta.model_name}-{blocked_by.id} to finish")
|
||||||
|
if task.job_explanation != job_explanation:
|
||||||
|
if task.created < (tz_now() - self.time_delta_job_explanation):
|
||||||
|
task.job_explanation = job_explanation
|
||||||
|
tasks_to_update_job_explanation.append(task)
|
||||||
continue
|
continue
|
||||||
preferred_instance_groups = task.preferred_instance_groups
|
preferred_instance_groups = task.preferred_instance_groups
|
||||||
found_acceptable_queue = False
|
found_acceptable_queue = False
|
||||||
@@ -539,7 +553,17 @@ class TaskManager():
|
|||||||
logger.debug("No instance available in group {} to run job {} w/ capacity requirement {}".format(
|
logger.debug("No instance available in group {} to run job {} w/ capacity requirement {}".format(
|
||||||
rampart_group.name, task.log_format, task.task_impact))
|
rampart_group.name, task.log_format, task.task_impact))
|
||||||
if not found_acceptable_queue:
|
if not found_acceptable_queue:
|
||||||
|
task.log_lifecycle("needs_capacity")
|
||||||
|
job_explanation = gettext_noop("This job is not ready to start because there is not enough available capacity.")
|
||||||
|
if task.job_explanation != job_explanation:
|
||||||
|
if task.created < (tz_now() - self.time_delta_job_explanation):
|
||||||
|
# Many launched jobs are immediately blocked, but most blocks will resolve in a few seconds.
|
||||||
|
# Therefore we should only update the job_explanation after some time has elapsed to
|
||||||
|
# prevent excessive task saves.
|
||||||
|
task.job_explanation = job_explanation
|
||||||
|
tasks_to_update_job_explanation.append(task)
|
||||||
logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format))
|
logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format))
|
||||||
|
UnifiedJob.objects.bulk_update(tasks_to_update_job_explanation, ['job_explanation'])
|
||||||
|
|
||||||
def timeout_approval_node(self):
|
def timeout_approval_node(self):
|
||||||
workflow_approvals = WorkflowApproval.objects.filter(status='pending')
|
workflow_approvals = WorkflowApproval.objects.filter(status='pending')
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ def send_notifications(notification_list, job_id=None):
|
|||||||
sent = notification.notification_template.send(notification.subject, notification.body)
|
sent = notification.notification_template.send(notification.subject, notification.body)
|
||||||
notification.status = "successful"
|
notification.status = "successful"
|
||||||
notification.notifications_sent = sent
|
notification.notifications_sent = sent
|
||||||
|
if job_id is not None:
|
||||||
|
job_actual.log_lifecycle("notifications_sent")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Send Notification Failed {}".format(e))
|
logger.exception("Send Notification Failed {}".format(e))
|
||||||
notification.status = "failed"
|
notification.status = "failed"
|
||||||
@@ -1186,16 +1188,19 @@ class BaseTask(object):
|
|||||||
'''
|
'''
|
||||||
Hook for any steps to run before the job/task starts
|
Hook for any steps to run before the job/task starts
|
||||||
'''
|
'''
|
||||||
|
instance.log_lifecycle("pre_run")
|
||||||
|
|
||||||
def post_run_hook(self, instance, status):
|
def post_run_hook(self, instance, status):
|
||||||
'''
|
'''
|
||||||
Hook for any steps to run before job/task is marked as complete.
|
Hook for any steps to run before job/task is marked as complete.
|
||||||
'''
|
'''
|
||||||
|
instance.log_lifecycle("post_run")
|
||||||
|
|
||||||
def final_run_hook(self, instance, status, private_data_dir, fact_modification_times, isolated_manager_instance=None):
|
def final_run_hook(self, instance, status, private_data_dir, fact_modification_times, isolated_manager_instance=None):
|
||||||
'''
|
'''
|
||||||
Hook for any steps to run after job/task is marked as complete.
|
Hook for any steps to run after job/task is marked as complete.
|
||||||
'''
|
'''
|
||||||
|
instance.log_lifecycle("finalize_run")
|
||||||
job_profiling_dir = os.path.join(private_data_dir, 'artifacts/playbook_profiling')
|
job_profiling_dir = os.path.join(private_data_dir, 'artifacts/playbook_profiling')
|
||||||
awx_profiling_dir = '/var/log/tower/playbook_profiling/'
|
awx_profiling_dir = '/var/log/tower/playbook_profiling/'
|
||||||
if not os.path.exists(awx_profiling_dir):
|
if not os.path.exists(awx_profiling_dir):
|
||||||
@@ -1358,7 +1363,6 @@ class BaseTask(object):
|
|||||||
# self.instance because of the update_model pattern and when it's used in callback handlers
|
# self.instance because of the update_model pattern and when it's used in callback handlers
|
||||||
self.instance = self.update_model(pk, status='running',
|
self.instance = self.update_model(pk, status='running',
|
||||||
start_args='') # blank field to remove encrypted passwords
|
start_args='') # blank field to remove encrypted passwords
|
||||||
|
|
||||||
self.instance.websocket_emit_status("running")
|
self.instance.websocket_emit_status("running")
|
||||||
status, rc = 'error', None
|
status, rc = 'error', None
|
||||||
extra_update_fields = {}
|
extra_update_fields = {}
|
||||||
@@ -1383,6 +1387,7 @@ class BaseTask(object):
|
|||||||
self.instance.send_notification_templates("running")
|
self.instance.send_notification_templates("running")
|
||||||
private_data_dir = self.build_private_data_dir(self.instance)
|
private_data_dir = self.build_private_data_dir(self.instance)
|
||||||
self.pre_run_hook(self.instance, private_data_dir)
|
self.pre_run_hook(self.instance, private_data_dir)
|
||||||
|
self.instance.log_lifecycle("preparing_playbook")
|
||||||
if self.instance.cancel_flag:
|
if self.instance.cancel_flag:
|
||||||
self.instance = self.update_model(self.instance.pk, status='canceled')
|
self.instance = self.update_model(self.instance.pk, status='canceled')
|
||||||
if self.instance.status != 'running':
|
if self.instance.status != 'running':
|
||||||
@@ -1510,6 +1515,7 @@ class BaseTask(object):
|
|||||||
res = ansible_runner.interface.run(**params)
|
res = ansible_runner.interface.run(**params)
|
||||||
status = res.status
|
status = res.status
|
||||||
rc = res.rc
|
rc = res.rc
|
||||||
|
self.instance.log_lifecycle("running_playbook")
|
||||||
|
|
||||||
if status == 'timeout':
|
if status == 'timeout':
|
||||||
self.instance.job_explanation = "Job terminated due to timeout"
|
self.instance.job_explanation = "Job terminated due to timeout"
|
||||||
@@ -1868,6 +1874,7 @@ class RunJob(BaseTask):
|
|||||||
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
||||||
|
|
||||||
def pre_run_hook(self, job, private_data_dir):
|
def pre_run_hook(self, job, private_data_dir):
|
||||||
|
super(RunJob, self).pre_run_hook(job, private_data_dir)
|
||||||
if job.inventory is None:
|
if job.inventory is None:
|
||||||
error = _('Job could not start because it does not have a valid inventory.')
|
error = _('Job could not start because it does not have a valid inventory.')
|
||||||
self.update_model(job.pk, status='failed', job_explanation=error)
|
self.update_model(job.pk, status='failed', job_explanation=error)
|
||||||
@@ -2313,6 +2320,7 @@ class RunProjectUpdate(BaseTask):
|
|||||||
'for path {}.'.format(instance.log_format, waiting_time, lock_path))
|
'for path {}.'.format(instance.log_format, waiting_time, lock_path))
|
||||||
|
|
||||||
def pre_run_hook(self, instance, private_data_dir):
|
def pre_run_hook(self, instance, private_data_dir):
|
||||||
|
super(RunProjectUpdate, self).pre_run_hook(instance, private_data_dir)
|
||||||
# re-create root project folder if a natural disaster has destroyed it
|
# re-create root project folder if a natural disaster has destroyed it
|
||||||
if not os.path.exists(settings.PROJECTS_ROOT):
|
if not os.path.exists(settings.PROJECTS_ROOT):
|
||||||
os.mkdir(settings.PROJECTS_ROOT)
|
os.mkdir(settings.PROJECTS_ROOT)
|
||||||
@@ -2408,6 +2416,7 @@ class RunProjectUpdate(BaseTask):
|
|||||||
logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath))
|
logger.debug('{0} {1} prepared {2} from cache'.format(type(p).__name__, p.pk, dest_subpath))
|
||||||
|
|
||||||
def post_run_hook(self, instance, status):
|
def post_run_hook(self, instance, status):
|
||||||
|
super(RunProjectUpdate, self).post_run_hook(instance, status)
|
||||||
# To avoid hangs, very important to release lock even if errors happen here
|
# To avoid hangs, very important to release lock even if errors happen here
|
||||||
try:
|
try:
|
||||||
if self.playbook_new_revision:
|
if self.playbook_new_revision:
|
||||||
@@ -2663,6 +2672,7 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
return inventory_update.get_extra_credentials()
|
return inventory_update.get_extra_credentials()
|
||||||
|
|
||||||
def pre_run_hook(self, inventory_update, private_data_dir):
|
def pre_run_hook(self, inventory_update, private_data_dir):
|
||||||
|
super(RunInventoryUpdate, self).pre_run_hook(inventory_update, private_data_dir)
|
||||||
source_project = None
|
source_project = None
|
||||||
if inventory_update.inventory_source:
|
if inventory_update.inventory_source:
|
||||||
source_project = inventory_update.inventory_source.source_project
|
source_project = inventory_update.inventory_source.source_project
|
||||||
@@ -2707,6 +2717,7 @@ class RunInventoryUpdate(BaseTask):
|
|||||||
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
|
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
|
||||||
|
|
||||||
def post_run_hook(self, inventory_update, status):
|
def post_run_hook(self, inventory_update, status):
|
||||||
|
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
||||||
if status != 'successful':
|
if status != 'successful':
|
||||||
return # nothing to save, step out of the way to allow error reporting
|
return # nothing to save, step out of the way to allow error reporting
|
||||||
|
|
||||||
|
|||||||
@@ -393,3 +393,43 @@ def test_saml_x509cert_validation(patch, get, admin, headers):
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_github_settings(get, put, patch, delete, admin):
|
||||||
|
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github'})
|
||||||
|
get(url, user=admin, expect=200)
|
||||||
|
delete(url, user=admin, expect=204)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
data = dict(response.data.items())
|
||||||
|
put(url, user=admin, data=data, expect=200)
|
||||||
|
patch(url, user=admin, data={'SOCIAL_AUTH_GITHUB_KEY': '???'}, expect=200)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == '???'
|
||||||
|
data.pop('SOCIAL_AUTH_GITHUB_KEY')
|
||||||
|
put(url, user=admin, data=data, expect=200)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_github_enterprise_settings(get, put, patch, delete, admin):
|
||||||
|
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github-enterprise'})
|
||||||
|
get(url, user=admin, expect=200)
|
||||||
|
delete(url, user=admin, expect=204)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
data = dict(response.data.items())
|
||||||
|
put(url, user=admin, data=data, expect=200)
|
||||||
|
patch(url, user=admin, data={
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'example.com',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'example.com',
|
||||||
|
}, expect=200)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == 'example.com'
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == 'example.com'
|
||||||
|
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_URL')
|
||||||
|
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL')
|
||||||
|
put(url, user=admin, data=data, expect=200)
|
||||||
|
response = get(url, user=admin, expect=200)
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == ''
|
||||||
|
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == ''
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
|
|
||||||
#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin
|
#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin
|
||||||
from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate,
|
from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate,
|
||||||
SystemJob, WorkflowJob)
|
Schedule, SystemJob, WorkflowJob)
|
||||||
from awx.api.serializers import UnifiedJobSerializer
|
from awx.api.serializers import UnifiedJobSerializer
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +72,10 @@ class TestJobNotificationMixin(object):
|
|||||||
'name': str,
|
'name': str,
|
||||||
'scm_type': str,
|
'scm_type': str,
|
||||||
'status': str},
|
'status': str},
|
||||||
|
'schedule': {'description': str,
|
||||||
|
'id': int,
|
||||||
|
'name': str,
|
||||||
|
'next_run': datetime.datetime},
|
||||||
'unified_job_template': {'description': str,
|
'unified_job_template': {'description': str,
|
||||||
'id': int,
|
'id': int,
|
||||||
'name': str,
|
'name': str,
|
||||||
@@ -89,27 +93,27 @@ class TestJobNotificationMixin(object):
|
|||||||
'workflow_url': str,
|
'workflow_url': str,
|
||||||
'url': str}
|
'url': str}
|
||||||
|
|
||||||
|
def check_structure(self, expected_structure, obj):
|
||||||
|
if isinstance(expected_structure, dict):
|
||||||
|
assert isinstance(obj, dict)
|
||||||
|
for key in obj:
|
||||||
|
assert key in expected_structure
|
||||||
|
if obj[key] is None:
|
||||||
|
continue
|
||||||
|
if isinstance(expected_structure[key], dict):
|
||||||
|
assert isinstance(obj[key], dict)
|
||||||
|
self.check_structure(expected_structure[key], obj[key])
|
||||||
|
else:
|
||||||
|
if key == 'job_explanation':
|
||||||
|
assert isinstance(str(obj[key]), expected_structure[key])
|
||||||
|
else:
|
||||||
|
assert isinstance(obj[key], expected_structure[key])
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@pytest.mark.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob])
|
@pytest.mark.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob])
|
||||||
def test_context(self, JobClass, sqlite_copy_expert, project, inventory_source):
|
def test_context(self, JobClass, sqlite_copy_expert, project, inventory_source):
|
||||||
"""The Jinja context defines all of the fields that can be used by a template. Ensure that the context generated
|
"""The Jinja context defines all of the fields that can be used by a template. Ensure that the context generated
|
||||||
for each job type has the expected structure."""
|
for each job type has the expected structure."""
|
||||||
def check_structure(expected_structure, obj):
|
|
||||||
if isinstance(expected_structure, dict):
|
|
||||||
assert isinstance(obj, dict)
|
|
||||||
for key in obj:
|
|
||||||
assert key in expected_structure
|
|
||||||
if obj[key] is None:
|
|
||||||
continue
|
|
||||||
if isinstance(expected_structure[key], dict):
|
|
||||||
assert isinstance(obj[key], dict)
|
|
||||||
check_structure(expected_structure[key], obj[key])
|
|
||||||
else:
|
|
||||||
if key == 'job_explanation':
|
|
||||||
assert isinstance(str(obj[key]), expected_structure[key])
|
|
||||||
else:
|
|
||||||
assert isinstance(obj[key], expected_structure[key])
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if JobClass is InventoryUpdate:
|
if JobClass is InventoryUpdate:
|
||||||
kwargs['inventory_source'] = inventory_source
|
kwargs['inventory_source'] = inventory_source
|
||||||
@@ -121,8 +125,26 @@ class TestJobNotificationMixin(object):
|
|||||||
job_serialization = UnifiedJobSerializer(job).to_representation(job)
|
job_serialization = UnifiedJobSerializer(job).to_representation(job)
|
||||||
|
|
||||||
context = job.context(job_serialization)
|
context = job.context(job_serialization)
|
||||||
check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
|
self.check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_schedule_context(self, job_template, admin_user):
|
||||||
|
schedule = Schedule.objects.create(
|
||||||
|
name='job-schedule',
|
||||||
|
rrule='DTSTART:20171129T155939z\nFREQ=MONTHLY',
|
||||||
|
unified_job_template=job_template
|
||||||
|
)
|
||||||
|
job = Job.objects.create(
|
||||||
|
name='fake-job',
|
||||||
|
launch_type='workflow',
|
||||||
|
schedule=schedule,
|
||||||
|
job_template=job_template
|
||||||
|
)
|
||||||
|
|
||||||
|
job_serialization = UnifiedJobSerializer(job).to_representation(job)
|
||||||
|
|
||||||
|
context = job.context(job_serialization)
|
||||||
|
self.check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_context_job_metadata_with_unicode(self):
|
def test_context_job_metadata_with_unicode(self):
|
||||||
|
|||||||
@@ -348,11 +348,11 @@ def test_job_not_blocking_project_update(default_instance_group, job_template_fa
|
|||||||
project_update.instance_group = default_instance_group
|
project_update.instance_group = default_instance_group
|
||||||
project_update.status = "pending"
|
project_update.status = "pending"
|
||||||
project_update.save()
|
project_update.save()
|
||||||
assert not task_manager.is_job_blocked(project_update)
|
assert not task_manager.job_blocked_by(project_update)
|
||||||
|
|
||||||
dependency_graph = DependencyGraph(None)
|
dependency_graph = DependencyGraph()
|
||||||
dependency_graph.add_job(job)
|
dependency_graph.add_job(job)
|
||||||
assert not dependency_graph.is_job_blocked(project_update)
|
assert not dependency_graph.task_blocked_by(project_update)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -378,11 +378,11 @@ def test_job_not_blocking_inventory_update(default_instance_group, job_template_
|
|||||||
inventory_update.status = "pending"
|
inventory_update.status = "pending"
|
||||||
inventory_update.save()
|
inventory_update.save()
|
||||||
|
|
||||||
assert not task_manager.is_job_blocked(inventory_update)
|
assert not task_manager.job_blocked_by(inventory_update)
|
||||||
|
|
||||||
dependency_graph = DependencyGraph(None)
|
dependency_graph = DependencyGraph()
|
||||||
dependency_graph.add_job(job)
|
dependency_graph.add_job(job)
|
||||||
assert not dependency_graph.is_job_blocked(inventory_update)
|
assert not dependency_graph.task_blocked_by(inventory_update)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ data_loggly = {
|
|||||||
# Test reconfigure logging settings function
|
# Test reconfigure logging settings function
|
||||||
# name this whatever you want
|
# name this whatever you want
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'enabled, log_type, host, port, protocol, expected_config', [
|
'enabled, log_type, host, port, protocol, errorfile, expected_config', [
|
||||||
(
|
(
|
||||||
True,
|
True,
|
||||||
'loggly',
|
'loggly',
|
||||||
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
|
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
|
||||||
None,
|
None,
|
||||||
'https',
|
'https',
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
'action(type="omhttp" server="logs-01.loggly.com" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -53,6 +54,7 @@ data_loggly = {
|
|||||||
'localhost',
|
'localhost',
|
||||||
9000,
|
9000,
|
||||||
'udp',
|
'udp',
|
||||||
|
'', # empty errorfile
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||||
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
'action(type="omfwd" target="localhost" port="9000" protocol="udp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
||||||
@@ -64,6 +66,7 @@ data_loggly = {
|
|||||||
'localhost',
|
'localhost',
|
||||||
9000,
|
9000,
|
||||||
'tcp',
|
'tcp',
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")',
|
||||||
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
'action(type="omfwd" target="localhost" port="9000" protocol="tcp" action.resumeRetryCount="-1" action.resumeInterval="5" template="awx")', # noqa
|
||||||
@@ -75,9 +78,10 @@ data_loggly = {
|
|||||||
'https://yoursplunk/services/collector/event',
|
'https://yoursplunk/services/collector/event',
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -86,9 +90,10 @@ data_loggly = {
|
|||||||
'http://yoursplunk/services/collector/event',
|
'http://yoursplunk/services/collector/event',
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="80" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -97,9 +102,10 @@ data_loggly = {
|
|||||||
'https://yoursplunk:8088/services/collector/event',
|
'https://yoursplunk:8088/services/collector/event',
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -108,9 +114,10 @@ data_loggly = {
|
|||||||
'https://yoursplunk/services/collector/event',
|
'https://yoursplunk/services/collector/event',
|
||||||
8088,
|
8088,
|
||||||
None,
|
None,
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -119,9 +126,10 @@ data_loggly = {
|
|||||||
'yoursplunk.org/services/collector/event',
|
'yoursplunk.org/services/collector/event',
|
||||||
8088,
|
8088,
|
||||||
'https',
|
'https',
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -130,9 +138,10 @@ data_loggly = {
|
|||||||
'http://yoursplunk.org/services/collector/event',
|
'http://yoursplunk.org/services/collector/event',
|
||||||
8088,
|
8088,
|
||||||
None,
|
None,
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="services/collector/event")', # noqa
|
'action(type="omhttp" server="yoursplunk.org" serverport="8088" usehttps="off" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="services/collector/event")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -141,14 +150,15 @@ data_loggly = {
|
|||||||
'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa
|
'https://endpoint5.collection.us2.sumologic.com/receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==', # noqa
|
||||||
None,
|
None,
|
||||||
'https',
|
'https',
|
||||||
|
'/var/log/tower/rsyslog.err',
|
||||||
'\n'.join([
|
'\n'.join([
|
||||||
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
'template(name="awx" type="string" string="%rawmsg-after-pri%")\nmodule(load="omhttp")',
|
||||||
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" errorfile="/var/log/tower/rsyslog.err" action.resumeInterval="5" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
'action(type="omhttp" server="endpoint5.collection.us2.sumologic.com" serverport="443" usehttps="on" allowunsignedcerts="off" skipverifyhost="off" action.resumeRetryCount="-1" template="awx" action.resumeInterval="5" errorfile="/var/log/tower/rsyslog.err" restpath="receiver/v1/http/ZaVnC4dhaV0qoiETY0MrM3wwLoDgO1jFgjOxE6-39qokkj3LGtOroZ8wNaN2M6DtgYrJZsmSi4-36_Up5TbbN_8hosYonLKHSSOSKY845LuLZBCBwStrHQ==")', # noqa
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected_config):
|
def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, errorfile, expected_config):
|
||||||
|
|
||||||
mock_settings, _ = _mock_logging_defaults()
|
mock_settings, _ = _mock_logging_defaults()
|
||||||
|
|
||||||
@@ -159,6 +169,7 @@ def test_rsyslog_conf_template(enabled, log_type, host, port, protocol, expected
|
|||||||
setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled)
|
setattr(mock_settings, 'LOG_AGGREGATOR_ENABLED', enabled)
|
||||||
setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type)
|
setattr(mock_settings, 'LOG_AGGREGATOR_TYPE', log_type)
|
||||||
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
|
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
|
||||||
|
setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile)
|
||||||
if port:
|
if port:
|
||||||
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
|
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
|
||||||
if protocol:
|
if protocol:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
||||||
max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
|
max_disk_space = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_GB', 1)
|
||||||
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
|
spool_directory = getattr(settings, 'LOG_AGGREGATOR_MAX_DISK_USAGE_PATH', '/var/lib/awx').rstrip('/')
|
||||||
|
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
|
||||||
|
|
||||||
if not os.access(spool_directory, os.W_OK):
|
if not os.access(spool_directory, os.W_OK):
|
||||||
spool_directory = '/var/lib/awx'
|
spool_directory = '/var/lib/awx'
|
||||||
@@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings):
|
|||||||
f'skipverifyhost="{skip_verify}"',
|
f'skipverifyhost="{skip_verify}"',
|
||||||
'action.resumeRetryCount="-1"',
|
'action.resumeRetryCount="-1"',
|
||||||
'template="awx"',
|
'template="awx"',
|
||||||
'errorfile="/var/log/tower/rsyslog.err"',
|
|
||||||
f'action.resumeInterval="{timeout}"'
|
f'action.resumeInterval="{timeout}"'
|
||||||
]
|
]
|
||||||
|
if error_log_file:
|
||||||
|
params.append(f'errorfile="{error_log_file}"')
|
||||||
if parsed.path:
|
if parsed.path:
|
||||||
path = urlparse.quote(parsed.path[1:], safe='/=')
|
path = urlparse.quote(parsed.path[1:], safe='/=')
|
||||||
if parsed.query:
|
if parsed.query:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from copy import copy
|
from copy import copy
|
||||||
import json
|
import json
|
||||||
|
import json_log_formatter
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import socket
|
import socket
|
||||||
@@ -14,6 +15,15 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class JobLifeCycleFormatter(json_log_formatter.JSONFormatter):
|
||||||
|
def json_record(self, message: str, extra: dict, record: logging.LogRecord):
|
||||||
|
if 'time' not in extra:
|
||||||
|
extra['time'] = now()
|
||||||
|
if record.exc_info:
|
||||||
|
extra['exc_info'] = self.formatException(record.exc_info)
|
||||||
|
return extra
|
||||||
|
|
||||||
|
|
||||||
class TimeFormatter(logging.Formatter):
|
class TimeFormatter(logging.Formatter):
|
||||||
'''
|
'''
|
||||||
Custom log formatter used for inventory imports
|
Custom log formatter used for inventory imports
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ if settings.COLOR_LOGS is True:
|
|||||||
from logutils.colorize import ColorizingStreamHandler
|
from logutils.colorize import ColorizingStreamHandler
|
||||||
|
|
||||||
class ColorHandler(ColorizingStreamHandler):
|
class ColorHandler(ColorizingStreamHandler):
|
||||||
|
def colorize(self, line, record):
|
||||||
|
# comment out this method if you don't like the job_lifecycle
|
||||||
|
# logs rendered with cyan text
|
||||||
|
previous_level_map = self.level_map.copy()
|
||||||
|
if record.name == "awx.analytics.job_lifecycle":
|
||||||
|
self.level_map[logging.DEBUG] = (None, 'cyan', True)
|
||||||
|
msg = super(ColorHandler, self).colorize(line, record)
|
||||||
|
self.level_map = previous_level_map
|
||||||
|
return msg
|
||||||
|
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
message = logging.StreamHandler.format(self, record)
|
message = logging.StreamHandler.format(self, record)
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ TEMPLATES = [
|
|||||||
'DIRS': [
|
'DIRS': [
|
||||||
os.path.join(BASE_DIR, 'templates'),
|
os.path.join(BASE_DIR, 'templates'),
|
||||||
os.path.join(BASE_DIR, 'ui_next', 'build'),
|
os.path.join(BASE_DIR, 'ui_next', 'build'),
|
||||||
|
os.path.join(BASE_DIR, 'ui_next', 'public')
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -289,6 +290,7 @@ INSTALLED_APPS = [
|
|||||||
'awx.main',
|
'awx.main',
|
||||||
'awx.api',
|
'awx.api',
|
||||||
'awx.ui',
|
'awx.ui',
|
||||||
|
'awx.ui_next',
|
||||||
'awx.sso',
|
'awx.sso',
|
||||||
'solo'
|
'solo'
|
||||||
]
|
]
|
||||||
@@ -344,6 +346,9 @@ AUTHENTICATION_BACKENDS = (
|
|||||||
'social_core.backends.github.GithubOAuth2',
|
'social_core.backends.github.GithubOAuth2',
|
||||||
'social_core.backends.github.GithubOrganizationOAuth2',
|
'social_core.backends.github.GithubOrganizationOAuth2',
|
||||||
'social_core.backends.github.GithubTeamOAuth2',
|
'social_core.backends.github.GithubTeamOAuth2',
|
||||||
|
'social_core.backends.github_enterprise.GithubEnterpriseOAuth2',
|
||||||
|
'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2',
|
||||||
|
'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2',
|
||||||
'social_core.backends.azuread.AzureADOAuth2',
|
'social_core.backends.azuread.AzureADOAuth2',
|
||||||
'awx.sso.backends.SAMLAuth',
|
'awx.sso.backends.SAMLAuth',
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
'django.contrib.auth.backends.ModelBackend',
|
||||||
@@ -520,6 +525,20 @@ SOCIAL_AUTH_GITHUB_TEAM_SECRET = ''
|
|||||||
SOCIAL_AUTH_GITHUB_TEAM_ID = ''
|
SOCIAL_AUTH_GITHUB_TEAM_ID = ''
|
||||||
SOCIAL_AUTH_GITHUB_TEAM_SCOPE = ['user:email', 'read:org']
|
SOCIAL_AUTH_GITHUB_TEAM_SCOPE = ['user:email', 'read:org']
|
||||||
|
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_SCOPE = ['user:email', 'read:org']
|
||||||
|
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SCOPE = ['user:email', 'read:org']
|
||||||
|
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID = ''
|
||||||
|
SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SCOPE = ['user:email', 'read:org']
|
||||||
|
|
||||||
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = ''
|
SOCIAL_AUTH_AZUREAD_OAUTH2_KEY = ''
|
||||||
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = ''
|
SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET = ''
|
||||||
|
|
||||||
@@ -770,6 +789,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO'
|
|||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
|
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
|
||||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
||||||
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
|
LOG_AGGREGATOR_RSYSLOGD_DEBUG = False
|
||||||
|
LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE = '/var/log/tower/rsyslog.err'
|
||||||
|
|
||||||
# The number of retry attempts for websocket session establishment
|
# The number of retry attempts for websocket session establishment
|
||||||
# If you're encountering issues establishing websockets in clustered Tower,
|
# If you're encountering issues establishing websockets in clustered Tower,
|
||||||
@@ -824,6 +844,9 @@ LOGGING = {
|
|||||||
'dispatcher': {
|
'dispatcher': {
|
||||||
'format': '%(asctime)s %(levelname)-8s %(name)s PID:%(process)d %(message)s',
|
'format': '%(asctime)s %(levelname)-8s %(name)s PID:%(process)d %(message)s',
|
||||||
},
|
},
|
||||||
|
'job_lifecycle': {
|
||||||
|
'()': 'awx.main.utils.formatters.JobLifeCycleFormatter',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'console': {
|
'console': {
|
||||||
@@ -853,38 +876,30 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
'tower_warnings': {
|
'tower_warnings': {
|
||||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'tower.log'),
|
'filename': os.path.join(LOG_ROOT, 'tower.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'callback_receiver': {
|
'callback_receiver': {
|
||||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
|
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'dispatcher': {
|
'dispatcher': {
|
||||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
|
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'dispatcher',
|
'formatter':'dispatcher',
|
||||||
},
|
},
|
||||||
'wsbroadcast': {
|
'wsbroadcast': {
|
||||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
|
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'celery.beat': {
|
'celery.beat': {
|
||||||
@@ -898,48 +913,44 @@ LOGGING = {
|
|||||||
},
|
},
|
||||||
'task_system': {
|
'task_system': {
|
||||||
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
# don't define a level here, it's set by settings.LOG_AGGREGATOR_LEVEL
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
'filters': ['require_debug_false', 'dynamic_level_filter'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'task_system.log'),
|
'filename': os.path.join(LOG_ROOT, 'task_system.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'management_playbooks': {
|
'management_playbooks': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
'class':'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false'],
|
'filters': ['require_debug_false'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
|
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'system_tracking_migrations': {
|
'system_tracking_migrations': {
|
||||||
'level': 'WARNING',
|
'level': 'WARNING',
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
'class':'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false'],
|
'filters': ['require_debug_false'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
|
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'rbac_migrations': {
|
'rbac_migrations': {
|
||||||
'level': 'WARNING',
|
'level': 'WARNING',
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
'class':'logging.handlers.WatchedFileHandler',
|
||||||
'filters': ['require_debug_false'],
|
'filters': ['require_debug_false'],
|
||||||
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
|
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
'isolated_manager': {
|
'isolated_manager': {
|
||||||
'level': 'WARNING',
|
'level': 'WARNING',
|
||||||
'class':'logging.handlers.RotatingFileHandler',
|
'class':'logging.handlers.WatchedFileHandler',
|
||||||
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
||||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
|
||||||
'backupCount': 5,
|
|
||||||
'formatter':'simple',
|
'formatter':'simple',
|
||||||
},
|
},
|
||||||
|
'job_lifecycle': {
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'class':'logging.handlers.WatchedFileHandler',
|
||||||
|
'filename': os.path.join(LOG_ROOT, 'job_lifecycle.log'),
|
||||||
|
'formatter': 'job_lifecycle',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'django': {
|
'django': {
|
||||||
@@ -1029,6 +1040,11 @@ LOGGING = {
|
|||||||
'level': 'INFO',
|
'level': 'INFO',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
|
'awx.analytics.job_lifecycle': {
|
||||||
|
'handlers': ['console', 'job_lifecycle'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False
|
||||||
|
},
|
||||||
'django_auth_ldap': {
|
'django_auth_ldap': {
|
||||||
'handlers': ['console', 'file', 'tower_warnings'],
|
'handlers': ['console', 'file', 'tower_warnings'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
|
|||||||
292
awx/sso/conf.py
292
awx/sso/conf.py
@@ -842,6 +842,298 @@ register(
|
|||||||
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
|
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# GITHUB ENTERPRISE OAUTH2 AUTHENTICATION SETTINGS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_CALLBACK_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
read_only=True,
|
||||||
|
default=SocialAuthCallbackURL('github-enterprise'),
|
||||||
|
label=_('GitHub Enterprise OAuth2 Callback URL'),
|
||||||
|
help_text=_('Provide this URL as the callback URL for your application as part '
|
||||||
|
'of your registration process. Refer to the Ansible Tower '
|
||||||
|
'documentation for more detail.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
depends_on=['TOWER_URL_BASE'],
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise URL'),
|
||||||
|
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
|
||||||
|
'documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise API URL'),
|
||||||
|
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
|
||||||
|
'Enterprise documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise OAuth2 Key'),
|
||||||
|
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise developer application.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise OAuth2 Secret'),
|
||||||
|
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise developer application.'),
|
||||||
|
category=_('GitHub OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
encrypted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORGANIZATION_MAP',
|
||||||
|
field_class=SocialOrganizationMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise OAuth2 Organization Map'),
|
||||||
|
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_MAP',
|
||||||
|
field_class=SocialTeamMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise OAuth2 Team Map'),
|
||||||
|
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise',
|
||||||
|
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# GITHUB ENTERPRISE ORG OAUTH2 AUTHENTICATION SETTINGS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_CALLBACK_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
read_only=True,
|
||||||
|
default=SocialAuthCallbackURL('github-enterprise-org'),
|
||||||
|
label=_('GitHub Enterprise Organization OAuth2 Callback URL'),
|
||||||
|
help_text=_('Provide this URL as the callback URL for your application as part '
|
||||||
|
'of your registration process. Refer to the Ansible Tower '
|
||||||
|
'documentation for more detail.'),
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
depends_on=['TOWER_URL_BASE'],
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Organization URL'),
|
||||||
|
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
|
||||||
|
'documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Organization API URL'),
|
||||||
|
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
|
||||||
|
'Enterprise documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Organization OAuth2 Key'),
|
||||||
|
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'),
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Organization OAuth2 Secret'),
|
||||||
|
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'),
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
encrypted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Organization Name'),
|
||||||
|
help_text=_('The name of your GitHub Enterprise organization, as used in your '
|
||||||
|
'organization\'s URL: https://github.com/<yourorg>/.'),
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_ORGANIZATION_MAP',
|
||||||
|
field_class=SocialOrganizationMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise Organization OAuth2 Organization Map'),
|
||||||
|
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_TEAM_MAP',
|
||||||
|
field_class=SocialTeamMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise Organization OAuth2 Team Map'),
|
||||||
|
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise Organization OAuth2'),
|
||||||
|
category_slug='github-enterprise-org',
|
||||||
|
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# GITHUB ENTERPRISE TEAM OAUTH2 AUTHENTICATION SETTINGS
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_CALLBACK_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
read_only=True,
|
||||||
|
default=SocialAuthCallbackURL('github-enterprise-team'),
|
||||||
|
label=_('GitHub Enterprise Team OAuth2 Callback URL'),
|
||||||
|
help_text=_('Create an organization-owned application at '
|
||||||
|
'https://github.com/organizations/<yourorg>/settings/applications '
|
||||||
|
'and obtain an OAuth2 key (Client ID) and secret (Client Secret). '
|
||||||
|
'Provide this URL as the callback URL for your application.'),
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
depends_on=['TOWER_URL_BASE'],
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Team URL'),
|
||||||
|
help_text=_('The URL for your Github Enterprise instance, e.g.: http(s)://hostname/. Refer to Github Enterprise '
|
||||||
|
'documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Team API URL'),
|
||||||
|
help_text=_('The API URL for your GitHub Enterprise instance, e.g.: http(s)://hostname/api/v3/. Refer to Github '
|
||||||
|
'Enterprise documentation for more details.'),
|
||||||
|
category=_('GitHub Enterprise OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Team OAuth2 Key'),
|
||||||
|
help_text=_('The OAuth2 key (Client ID) from your GitHub Enterprise organization application.'),
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Team OAuth2 Secret'),
|
||||||
|
help_text=_('The OAuth2 secret (Client Secret) from your GitHub Enterprise organization application.'),
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
encrypted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID',
|
||||||
|
field_class=fields.CharField,
|
||||||
|
allow_blank=True,
|
||||||
|
default='',
|
||||||
|
label=_('GitHub Enterprise Team ID'),
|
||||||
|
help_text=_('Find the numeric team ID using the Github Enterprise API: '
|
||||||
|
'http://fabian-kostadinov.github.io/2015/01/16/how-to-find-a-github-team-id/.'),
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ORGANIZATION_MAP',
|
||||||
|
field_class=SocialOrganizationMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise Team OAuth2 Organization Map'),
|
||||||
|
help_text=SOCIAL_AUTH_ORGANIZATION_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
placeholder=SOCIAL_AUTH_ORGANIZATION_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
register(
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_TEAM_MAP',
|
||||||
|
field_class=SocialTeamMapField,
|
||||||
|
allow_null=True,
|
||||||
|
default=None,
|
||||||
|
label=_('GitHub Enterprise Team OAuth2 Team Map'),
|
||||||
|
help_text=SOCIAL_AUTH_TEAM_MAP_HELP_TEXT,
|
||||||
|
category=_('GitHub Enterprise Team OAuth2'),
|
||||||
|
category_slug='github-enterprise-team',
|
||||||
|
placeholder=SOCIAL_AUTH_TEAM_MAP_PLACEHOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# MICROSOFT AZURE ACTIVE DIRECTORY SETTINGS
|
# MICROSOFT AZURE ACTIVE DIRECTORY SETTINGS
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|||||||
@@ -187,6 +187,26 @@ class AuthenticationBackendsField(fields.StringListField):
|
|||||||
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
|
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
|
||||||
'SOCIAL_AUTH_GITHUB_TEAM_ID',
|
'SOCIAL_AUTH_GITHUB_TEAM_ID',
|
||||||
]),
|
]),
|
||||||
|
('social_core.backends.github_enterprise.GithubEnterpriseOAuth2', [
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_KEY',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_SECRET',
|
||||||
|
]),
|
||||||
|
('social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2', [
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_KEY',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_SECRET',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME',
|
||||||
|
]),
|
||||||
|
('social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2', [
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_KEY',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_SECRET',
|
||||||
|
'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID',
|
||||||
|
]),
|
||||||
('social_core.backends.azuread.AzureADOAuth2', [
|
('social_core.backends.azuread.AzureADOAuth2', [
|
||||||
'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY',
|
'SOCIAL_AUTH_AZUREAD_OAUTH2_KEY',
|
||||||
'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET',
|
'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET',
|
||||||
|
|||||||
@@ -8,8 +8,14 @@
|
|||||||
"modules": true
|
"modules": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": ["react-hooks", "jsx-a11y"],
|
"plugins": ["react-hooks", "jsx-a11y", "i18next"],
|
||||||
"extends": ["airbnb", "prettier", "prettier/react", "plugin:jsx-a11y/strict"],
|
"extends": [
|
||||||
|
"airbnb",
|
||||||
|
"prettier",
|
||||||
|
"prettier/react",
|
||||||
|
"plugin:jsx-a11y/strict",
|
||||||
|
"plugin:i18next/recommended"
|
||||||
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
"react": {
|
"react": {
|
||||||
"version": "16.5.2"
|
"version": "16.5.2"
|
||||||
@@ -24,6 +30,70 @@
|
|||||||
"window": true
|
"window": true
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"i18next/no-literal-string": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"markupOnly": true,
|
||||||
|
"ignoreAttribute": [
|
||||||
|
"to",
|
||||||
|
"streamType",
|
||||||
|
"path",
|
||||||
|
"component",
|
||||||
|
"variant",
|
||||||
|
"key",
|
||||||
|
"position",
|
||||||
|
"promptName",
|
||||||
|
"color",
|
||||||
|
"promptId",
|
||||||
|
"headingLevel",
|
||||||
|
"size",
|
||||||
|
"target",
|
||||||
|
"autoComplete",
|
||||||
|
"trigger",
|
||||||
|
"from",
|
||||||
|
"name",
|
||||||
|
"fieldId",
|
||||||
|
"css",
|
||||||
|
"gutter",
|
||||||
|
"dataCy",
|
||||||
|
"tooltipMaxWidth",
|
||||||
|
"mode",
|
||||||
|
"aria-labelledby",
|
||||||
|
"aria-hidden",
|
||||||
|
"sortKey",
|
||||||
|
"ouiaId",
|
||||||
|
"credentialTypeNamespace",
|
||||||
|
"link",
|
||||||
|
"value",
|
||||||
|
"credentialTypeKind",
|
||||||
|
"linkTo",
|
||||||
|
"scrollToAlignment",
|
||||||
|
"displayKey",
|
||||||
|
"sortedColumnKey",
|
||||||
|
"maxHeight",
|
||||||
|
"role",
|
||||||
|
"aria-haspopup",
|
||||||
|
"dropDirection",
|
||||||
|
"resizeOrientation",
|
||||||
|
"src",
|
||||||
|
"theme",
|
||||||
|
"gridColumns"
|
||||||
|
],
|
||||||
|
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg", "START"],
|
||||||
|
"ignoreComponent": [
|
||||||
|
"code",
|
||||||
|
"Omit",
|
||||||
|
"PotentialLink",
|
||||||
|
"TypeRedirect",
|
||||||
|
"Radio",
|
||||||
|
"RunOnRadio",
|
||||||
|
"NodeTypeLetter",
|
||||||
|
"SelectableItem",
|
||||||
|
"Dash"
|
||||||
|
],
|
||||||
|
"ignoreCallee": ["describe"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"camelcase": "off",
|
"camelcase": "off",
|
||||||
"arrow-parens": "off",
|
"arrow-parens": "off",
|
||||||
"comma-dangle": "off",
|
"comma-dangle": "off",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ The AWX UI requires the following:
|
|||||||
|
|
||||||
Run the following to install all the dependencies:
|
Run the following to install all the dependencies:
|
||||||
```bash
|
```bash
|
||||||
(host) $ npm run install
|
(host) $ npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Build the User Interface
|
#### Build the User Interface
|
||||||
|
|||||||
10
awx/ui_next/apps.py
Normal file
10
awx/ui_next/apps.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Django
|
||||||
|
from django.apps import AppConfig
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class UINextConfig(AppConfig):
|
||||||
|
|
||||||
|
name = 'awx.ui_next'
|
||||||
|
verbose_name = _('UI_Next')
|
||||||
|
|
||||||
15
awx/ui_next/package-lock.json
generated
15
awx/ui_next/package-lock.json
generated
@@ -7172,6 +7172,15 @@
|
|||||||
"lodash": "^4.17.15"
|
"lodash": "^4.17.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"eslint-plugin-i18next": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ixbgSMrSb0dZsO6WPElg4JvPiQKLDA3ZpBuayxToADan1TKcbzKXT2A42Vyc0lEDhJRPL6uZnmm8vPjODDJypg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"requireindex": "~1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"eslint-plugin-import": {
|
"eslint-plugin-import": {
|
||||||
"version": "2.22.1",
|
"version": "2.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz",
|
||||||
@@ -15163,6 +15172,12 @@
|
|||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"requireindex": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz",
|
||||||
|
"integrity": "sha1-5UBLgVV+91225JxacgBIk/4D4WI=",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"requires-port": {
|
"requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"eslint-config-airbnb": "^17.1.0",
|
"eslint-config-airbnb": "^17.1.0",
|
||||||
"eslint-config-prettier": "^5.0.0",
|
"eslint-config-prettier": "^5.0.0",
|
||||||
"eslint-import-resolver-webpack": "0.11.1",
|
"eslint-import-resolver-webpack": "0.11.1",
|
||||||
|
"eslint-plugin-i18next": "^5.0.0",
|
||||||
"eslint-plugin-import": "^2.14.0",
|
"eslint-plugin-import": "^2.14.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||||
"eslint-plugin-react": "^7.11.1",
|
"eslint-plugin-react": "^7.11.1",
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
|
{% load static %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en-US">
|
<html lang="en-US">
|
||||||
<head>
|
<head>
|
||||||
<meta
|
<title>{{ title }}</title>
|
||||||
http-equiv="Content-Security-Policy"
|
<meta
|
||||||
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'nonce-{{ csp_nonce }}'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:;"
|
http-equiv="Content-Security-Policy"
|
||||||
/>
|
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'nonce-{{ csp_nonce }}'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:;"
|
||||||
<meta charset="utf-8">
|
/>
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<script nonce="{{ csp_nonce }}">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
setInterval(function() {
|
<link href="{% static 'css/fonts/assets/RedHatDisplay/RedHatDisplay-Medium.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
|
||||||
window.location = '/';
|
<link href="{% static 'css/fonts/assets/RedHatText/RedHatText-Regular.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
|
||||||
}, 10000);
|
<link href="{% static 'css/patternfly.min.css' %}" rel="stylesheet" type="text/css" media="all"/>
|
||||||
</script>
|
<script nonce="{{ csp_nonce }}">
|
||||||
</head>
|
setInterval(function() {
|
||||||
<body>
|
window.location = '/';
|
||||||
<div>
|
}, 10000);
|
||||||
<span>
|
</script>
|
||||||
<p>AWX is installing.</p>
|
</head>
|
||||||
<p>This page will refresh when complete.</p>
|
<body>
|
||||||
</span>
|
<div class="pf-l-bullseye pf-m-gutter">
|
||||||
</div>
|
<div class="pf-l-bullseye__item">
|
||||||
</body>
|
<div class="pf-l-bullseye">
|
||||||
|
<img src="{% static 'media/logo-header.svg' %}" width="300px" alt={{image_alt}} />
|
||||||
|
</div>
|
||||||
|
<div class="pf-l-bullseye">
|
||||||
|
<span class="pf-c-spinner" role="progressbar" aria-valuetext={{aria_spinner}}>
|
||||||
|
<span class="pf-c-spinner__clipper"></span>
|
||||||
|
<span class="pf-c-spinner__lead-ball"></span>
|
||||||
|
<span class="pf-c-spinner__tail-ball"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="pf-l-bullseye pf-c-title pf-m-2xl ws-heading ws-title ws-h2">{{message_upgrade}}</h2>
|
||||||
|
<h2 class="pf-l-bullseye pf-c-title pf-m-2xl ws-heading ws-title ws-h2">{{message_refresh}}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
2
awx/ui_next/public/static/css/patternfly.min.css
vendored
Normal file
2
awx/ui_next/public/static/css/patternfly.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
awx/ui_next/public/static/css/patternfly.min.css.map
Normal file
1
awx/ui_next/public/static/css/patternfly.min.css.map
Normal file
File diff suppressed because one or more lines are too long
@@ -34,7 +34,6 @@ function PageHeaderToolbar({
|
|||||||
const handleUserSelect = () => {
|
const handleUserSelect = () => {
|
||||||
setIsUserOpen(!isUserOpen);
|
setIsUserOpen(!isUserOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeaderTools>
|
<PageHeaderTools>
|
||||||
<PageHeaderToolsGroup>
|
<PageHeaderToolsGroup>
|
||||||
@@ -90,8 +89,11 @@ function PageHeaderToolbar({
|
|||||||
dropdownItems={[
|
dropdownItems={[
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key="user"
|
key="user"
|
||||||
|
aria-label={i18n._(t`User details`)}
|
||||||
href={
|
href={
|
||||||
loggedInUser ? `/users/${loggedInUser.id}/details` : '/home'
|
loggedInUser
|
||||||
|
? `/#/users/${loggedInUser.id}/details`
|
||||||
|
: '/#/home'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{i18n._(t`User Details`)}
|
{i18n._(t`User Details`)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => {
|
|||||||
<PageHeaderToolbar
|
<PageHeaderToolbar
|
||||||
onAboutClick={onAboutClick}
|
onAboutClick={onAboutClick}
|
||||||
onLogoutClick={onLogoutClick}
|
onLogoutClick={onLogoutClick}
|
||||||
|
loggedInUser={{ id: 1 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||||
@@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||||
wrapper.find(pageUserDropdownSelector).simulate('click');
|
wrapper.find(pageUserDropdownSelector).simulate('click');
|
||||||
|
wrapper.update();
|
||||||
|
expect(
|
||||||
|
wrapper.find('DropdownItem[aria-label="User details"]').prop('href')
|
||||||
|
).toBe('/#/users/1/details');
|
||||||
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
expect(wrapper.find('DropdownItem')).toHaveLength(2);
|
||||||
|
|
||||||
const logout = wrapper.find('DropdownItem li button');
|
const logout = wrapper.find('DropdownItem li button');
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function AssociateModal({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
key="cancel"
|
key="cancel"
|
||||||
variant="secondary"
|
variant="link"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
|
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
|
||||||
|
import { Trans, withI18n } from '@lingui/react';
|
||||||
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
|
||||||
import { DetailName, DetailValue } from '../DetailList';
|
import { DetailName, DetailValue } from '../DetailList';
|
||||||
import MultiButtonToggle from '../MultiButtonToggle';
|
import MultiButtonToggle from '../MultiButtonToggle';
|
||||||
@@ -111,7 +112,7 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
|
|||||||
css="color: var(--pf-global--danger-color--100);
|
css="color: var(--pf-global--danger-color--100);
|
||||||
font-size: var(--pf-global--FontSize--sm"
|
font-size: var(--pf-global--FontSize--sm"
|
||||||
>
|
>
|
||||||
Error: {error.message}
|
<Trans>Error:</Trans> {error.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DetailValue>
|
</DetailValue>
|
||||||
@@ -131,4 +132,4 @@ VariablesDetail.defaultProps = {
|
|||||||
helpText: '',
|
helpText: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VariablesDetail;
|
export default withI18n()(VariablesDetail);
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { shallow, mount } from 'enzyme';
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
import VariablesDetail from './VariablesDetail';
|
import VariablesDetail from './VariablesDetail';
|
||||||
|
|
||||||
jest.mock('../../api');
|
jest.mock('../../api');
|
||||||
|
|
||||||
describe('<VariablesDetail>', () => {
|
describe('<VariablesDetail>', () => {
|
||||||
test('should render readonly CodeMirrorInput', () => {
|
test('should render readonly CodeMirrorInput', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = mountWithContexts(
|
||||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||||
);
|
);
|
||||||
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
||||||
@@ -18,7 +18,7 @@ describe('<VariablesDetail>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should detect JSON', () => {
|
test('should detect JSON', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = mountWithContexts(
|
||||||
<VariablesDetail value='{"foo": "bar"}' label="Variables" />
|
<VariablesDetail value='{"foo": "bar"}' label="Variables" />
|
||||||
);
|
);
|
||||||
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
||||||
@@ -28,7 +28,7 @@ describe('<VariablesDetail>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should convert between modes', () => {
|
test('should convert between modes', () => {
|
||||||
const wrapper = shallow(
|
const wrapper = mountWithContexts(
|
||||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||||
);
|
);
|
||||||
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
||||||
@@ -43,7 +43,9 @@ describe('<VariablesDetail>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render label and value= --- when there are no values', () => {
|
test('should render label and value= --- when there are no values', () => {
|
||||||
const wrapper = shallow(<VariablesDetail value="" label="Variables" />);
|
const wrapper = mountWithContexts(
|
||||||
|
<VariablesDetail value="" label="Variables" />
|
||||||
|
);
|
||||||
expect(wrapper.find('VariablesDetail___StyledCodeMirrorInput').length).toBe(
|
expect(wrapper.find('VariablesDetail___StyledCodeMirrorInput').length).toBe(
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
@@ -51,7 +53,7 @@ describe('<VariablesDetail>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should update value if prop changes', () => {
|
test('should update value if prop changes', () => {
|
||||||
const wrapper = mount(
|
const wrapper = mountWithContexts(
|
||||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||||
);
|
);
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -67,13 +69,17 @@ describe('<VariablesDetail>', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should default yaml value to "---"', () => {
|
test('should default yaml value to "---"', () => {
|
||||||
const wrapper = shallow(<VariablesDetail value="" label="Variables" />);
|
const wrapper = mountWithContexts(
|
||||||
|
<VariablesDetail value="" label="Variables" />
|
||||||
|
);
|
||||||
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
||||||
expect(input.prop('value')).toEqual('---');
|
expect(input.prop('value')).toEqual('---');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should default empty json to "{}"', () => {
|
test('should default empty json to "{}"', () => {
|
||||||
const wrapper = mount(<VariablesDetail value="" label="Variables" />);
|
const wrapper = mountWithContexts(
|
||||||
|
<VariablesDetail value="" label="Variables" />
|
||||||
|
);
|
||||||
act(() => {
|
act(() => {
|
||||||
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import AlertModal from '../AlertModal';
|
|||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
|
|
||||||
function CopyButton({
|
function CopyButton({
|
||||||
i18n,
|
id,
|
||||||
copyItem,
|
copyItem,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
onCopyStart,
|
onCopyStart,
|
||||||
onCopyFinish,
|
onCopyFinish,
|
||||||
helperText,
|
helperText,
|
||||||
|
i18n,
|
||||||
}) {
|
}) {
|
||||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||||
copyItem
|
copyItem
|
||||||
@@ -34,6 +35,7 @@ function CopyButton({
|
|||||||
<>
|
<>
|
||||||
<Tooltip content={helperText.tooltip} position="top">
|
<Tooltip content={helperText.tooltip} position="top">
|
||||||
<Button
|
<Button
|
||||||
|
id={id}
|
||||||
isDisabled={isLoading || isDisabled}
|
isDisabled={isLoading || isDisabled}
|
||||||
aria-label={i18n._(t`Copy`)}
|
aria-label={i18n._(t`Copy`)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function DeleteButton({
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
key="cancel"
|
key="cancel"
|
||||||
variant="secondary"
|
variant="link"
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
|
|||||||
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal file
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import Detail from './Detail';
|
||||||
|
|
||||||
|
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
||||||
|
const {
|
||||||
|
created_by: createdBy,
|
||||||
|
job_template: jobTemplate,
|
||||||
|
schedule,
|
||||||
|
} = summary_fields;
|
||||||
|
const { schedule: relatedSchedule } = related;
|
||||||
|
|
||||||
|
if (!createdBy && !schedule) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let link;
|
||||||
|
let value;
|
||||||
|
|
||||||
|
if (createdBy) {
|
||||||
|
link = `/users/${createdBy.id}`;
|
||||||
|
value = createdBy.username;
|
||||||
|
} else if (relatedSchedule && jobTemplate) {
|
||||||
|
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
|
||||||
|
value = schedule.name;
|
||||||
|
} else {
|
||||||
|
link = null;
|
||||||
|
value = schedule.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { link, value };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LaunchedByDetail({ job, i18n }) {
|
||||||
|
const { value: launchedByValue, link: launchedByLink } =
|
||||||
|
getLaunchedByDetails(job) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Launched By`)}
|
||||||
|
value={
|
||||||
|
launchedByLink ? (
|
||||||
|
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
||||||
|
) : (
|
||||||
|
launchedByValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
|
|||||||
export { default as UserDateDetail } from './UserDateDetail';
|
export { default as UserDateDetail } from './UserDateDetail';
|
||||||
export { default as DetailBadge } from './DetailBadge';
|
export { default as DetailBadge } from './DetailBadge';
|
||||||
export { default as ArrayDetail } from './ArrayDetail';
|
export { default as ArrayDetail } from './ArrayDetail';
|
||||||
|
export { default as LaunchedByDetail } from './LaunchedByDetail';
|
||||||
/*
|
/*
|
||||||
NOTE: CodeDetail cannot be imported here, as it causes circular
|
NOTE: CodeDetail cannot be imported here, as it causes circular
|
||||||
dependencies in testing environment. Import it directly from
|
dependencies in testing environment. Import it directly from
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function DisassociateButton({
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
key="cancel"
|
key="cancel"
|
||||||
variant="secondary"
|
variant="link"
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const FormActionGroup = ({ onCancel, onSubmit, submitDisabled, i18n }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
|
|||||||
import AlertModal from '../AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import DatalistToolbar from '../DataListToolbar';
|
import DatalistToolbar from '../DataListToolbar';
|
||||||
import ErrorDetail from '../ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
|
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||||
|
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||||
import useRequest, {
|
import useRequest, {
|
||||||
useDeleteItems,
|
useDeleteItems,
|
||||||
useDismissableError,
|
useDismissableError,
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
} from '../../api';
|
} from '../../api';
|
||||||
|
|
||||||
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||||
const QS_CONFIG = getQSConfig(
|
const qsConfig = getQSConfig(
|
||||||
'job',
|
'job',
|
||||||
{
|
{
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(
|
useCallback(
|
||||||
async () => {
|
async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
const [response, actionsResponse] = await Promise.all([
|
const [response, actionsResponse] = await Promise.all([
|
||||||
UnifiedJobsAPI.read({ ...params }),
|
UnifiedJobsAPI.read({ ...params }),
|
||||||
UnifiedJobsAPI.readOptions(),
|
UnifiedJobsAPI.readOptions(),
|
||||||
@@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
// TODO: update QS_CONFIG to be safe for deps array
|
// TODO: update QS_CONFIG to be safe for deps array
|
||||||
const fetchJobsById = useCallback(
|
const fetchJobsById = useCallback(
|
||||||
async ids => {
|
async ids => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
params.id__in = ids.join(',');
|
params.id__in = ids.join(',');
|
||||||
const { data } = await UnifiedJobsAPI.read(params);
|
const { data } = await UnifiedJobsAPI.read(params);
|
||||||
return data.results;
|
return data.results;
|
||||||
@@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
);
|
);
|
||||||
|
|
||||||
const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG);
|
const jobs = useWsJobs(results, fetchJobsById, qsConfig);
|
||||||
|
|
||||||
const isAllSelected = selected.length === jobs.length && selected.length > 0;
|
const isAllSelected = selected.length === jobs.length && selected.length > 0;
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
);
|
);
|
||||||
}, [selected]),
|
}, [selected]),
|
||||||
{
|
{
|
||||||
qsConfig: QS_CONFIG,
|
qsConfig,
|
||||||
allItemsSelected: isAllSelected,
|
allItemsSelected: isAllSelected,
|
||||||
fetchItems: fetchJobs,
|
fetchItems: fetchJobs,
|
||||||
}
|
}
|
||||||
@@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
||||||
items={jobs}
|
items={jobs}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={i18n._(t`Jobs`)}
|
pluralizedItemName={i18n._(t`Jobs`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
onRowClick={handleSelect}
|
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
name: i18n._(t`Name`),
|
name: i18n._(t`Name`),
|
||||||
@@ -233,32 +233,18 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
key: 'job__limit',
|
key: 'job__limit',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
toolbarSortColumns={[
|
headerRow={
|
||||||
{
|
<HeaderRow qsConfig={qsConfig} isExpandable>
|
||||||
name: i18n._(t`Finish Time`),
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
key: 'finished',
|
<HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
|
||||||
},
|
{showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
|
||||||
{
|
<HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
|
||||||
name: i18n._(t`ID`),
|
<HeaderCell sortKey="finished">
|
||||||
key: 'id',
|
{i18n._(t`Finish Time`)}
|
||||||
},
|
</HeaderCell>
|
||||||
{
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
name: i18n._(t`Launched By`),
|
</HeaderRow>
|
||||||
key: 'created_by__id',
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Project`),
|
|
||||||
key: 'unified_job_template__project__id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Start Time`),
|
|
||||||
key: 'started',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
@@ -267,13 +253,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
showSelectAll
|
showSelectAll
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleJobDelete}
|
onDelete={handleJobDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName="Jobs"
|
pluralizedItemName={i18n._(t`Jobs`)}
|
||||||
/>,
|
/>,
|
||||||
<JobListCancelButton
|
<JobListCancelButton
|
||||||
key="cancel"
|
key="cancel"
|
||||||
@@ -283,13 +269,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={job => (
|
renderRow={(job, index) => (
|
||||||
<JobListItem
|
<JobListItem
|
||||||
key={job.id}
|
key={job.id}
|
||||||
job={job}
|
job={job}
|
||||||
showTypeColumn={showTypeColumn}
|
showTypeColumn={showTypeColumn}
|
||||||
onSelect={() => handleSelect(job)}
|
onSelect={() => handleSelect(job)}
|
||||||
isSelected={selected.some(row => row.id === job.id)}
|
isSelected={selected.some(row => row.id === job.id)}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,39 +1,31 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } 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 {
|
import { Button, Chip } from '@patternfly/react-core';
|
||||||
Button,
|
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemRow,
|
|
||||||
DataListItemCells,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { RocketIcon } from '@patternfly/react-icons';
|
import { RocketIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import DataListCell from '../DataListCell';
|
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||||
import LaunchButton from '../LaunchButton';
|
import LaunchButton from '../LaunchButton';
|
||||||
import StatusIcon from '../StatusIcon';
|
import StatusLabel from '../StatusLabel';
|
||||||
|
import { DetailList, Detail, LaunchedByDetail } from '../DetailList';
|
||||||
|
import ChipGroup from '../ChipGroup';
|
||||||
|
import CredentialChip from '../CredentialChip';
|
||||||
import { formatDateString } from '../../util/dates';
|
import { formatDateString } from '../../util/dates';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
const Dash = styled.span``;
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function JobListItem({
|
function JobListItem({
|
||||||
i18n,
|
i18n,
|
||||||
job,
|
job,
|
||||||
|
rowIndex,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
showTypeColumn = false,
|
showTypeColumn = false,
|
||||||
}) {
|
}) {
|
||||||
const labelId = `check-action-${job.id}`;
|
const labelId = `check-action-${job.id}`;
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
const jobTypes = {
|
const jobTypes = {
|
||||||
project_update: i18n._(t`Source Control Update`),
|
project_update: i18n._(t`Source Control Update`),
|
||||||
@@ -44,67 +36,123 @@ function JobListItem({
|
|||||||
workflow_job: i18n._(t`Workflow Job`),
|
workflow_job: i18n._(t`Workflow Job`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { credentials, inventory, labels } = job.summary_fields;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem aria-labelledby={labelId} id={`${job.id}`}>
|
<>
|
||||||
<DataListItemRow>
|
<Tr id={`job-row-${job.id}`}>
|
||||||
<DataListCheck
|
<Td
|
||||||
id={`select-job-${job.id}`}
|
expand={{
|
||||||
checked={isSelected}
|
rowIndex: job.id,
|
||||||
onChange={onSelect}
|
isExpanded,
|
||||||
aria-labelledby={labelId}
|
onToggle: () => setIsExpanded(!isExpanded),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<DataListItemCells
|
<Td
|
||||||
dataListCells={[
|
select={{
|
||||||
<DataListCell key="status" isFilled={false}>
|
rowIndex,
|
||||||
{job.status && <StatusIcon status={job.status} />}
|
isSelected,
|
||||||
</DataListCell>,
|
onSelect,
|
||||||
<DataListCell key="name">
|
}}
|
||||||
<span>
|
dataLabel={i18n._(t`Select`)}
|
||||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
|
||||||
<b>
|
|
||||||
{job.id} — {job.name}
|
|
||||||
</b>
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</DataListCell>,
|
|
||||||
...(showTypeColumn
|
|
||||||
? [
|
|
||||||
<DataListCell key="type" aria-label="type">
|
|
||||||
{jobTypes[job.type]}
|
|
||||||
</DataListCell>,
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
<DataListCell key="finished">
|
|
||||||
{job.finished ? formatDateString(job.finished) : ''}
|
|
||||||
</DataListCell>,
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
aria-label="actions"
|
<span>
|
||||||
aria-labelledby={labelId}
|
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||||
id={labelId}
|
<b>
|
||||||
>
|
{job.id} <Dash>—</Dash> {job.name}
|
||||||
{job.type !== 'system_job' &&
|
</b>
|
||||||
job.summary_fields?.user_capabilities?.start ? (
|
</Link>
|
||||||
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
</span>
|
||||||
<LaunchButton resource={job}>
|
</Td>
|
||||||
{({ handleRelaunch }) => (
|
<Td dataLabel={i18n._(t`Status`)}>
|
||||||
<Button
|
{job.status && <StatusLabel status={job.status} />}
|
||||||
variant="plain"
|
</Td>
|
||||||
onClick={handleRelaunch}
|
{showTypeColumn && (
|
||||||
aria-label={i18n._(t`Relaunch`)}
|
<Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
|
||||||
>
|
)}
|
||||||
<RocketIcon />
|
<Td dataLabel={i18n._(t`Start Time`)}>
|
||||||
</Button>
|
{formatDateString(job.started)}
|
||||||
)}
|
</Td>
|
||||||
</LaunchButton>
|
<Td dataLabel={i18n._(t`Finish Time`)}>
|
||||||
</Tooltip>
|
{job.finished ? formatDateString(job.finished) : ''}
|
||||||
) : (
|
</Td>
|
||||||
''
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
)}
|
<ActionItem
|
||||||
</DataListAction>
|
visible={
|
||||||
</DataListItemRow>
|
job.type !== 'system_job' &&
|
||||||
</DataListItem>
|
job.summary_fields?.user_capabilities?.start
|
||||||
|
}
|
||||||
|
tooltip={i18n._(t`Relaunch Job`)}
|
||||||
|
>
|
||||||
|
<LaunchButton resource={job}>
|
||||||
|
{({ handleRelaunch }) => (
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
onClick={handleRelaunch}
|
||||||
|
aria-label={i18n._(t`Relaunch`)}
|
||||||
|
>
|
||||||
|
<RocketIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</LaunchButton>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
</Tr>
|
||||||
|
<Tr isExpanded={isExpanded} id={`expanded-job-row-${job.id}`}>
|
||||||
|
<Td colSpan={2} />
|
||||||
|
<Td colSpan={showTypeColumn ? 5 : 4}>
|
||||||
|
<ExpandableRowContent>
|
||||||
|
<DetailList>
|
||||||
|
<LaunchedByDetail job={job} i18n={i18n} />
|
||||||
|
{credentials && credentials.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
fullWidth
|
||||||
|
label={i18n._(t`Credentials`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5} totalChips={credentials.length}>
|
||||||
|
{credentials.map(c => (
|
||||||
|
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{labels && labels.count > 0 && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Labels`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup numChips={5} totalChips={labels.results.length}>
|
||||||
|
{labels.results.map(l => (
|
||||||
|
<Chip key={l.id} isReadOnly>
|
||||||
|
{l.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{inventory && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
value={
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
inventory.kind === 'smart'
|
||||||
|
? `/inventories/smart_inventory/${inventory.id}`
|
||||||
|
: `/inventories/inventory/${inventory.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{inventory.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DetailList>
|
||||||
|
</ExpandableRowContent>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
|
|||||||
initialEntries: ['/jobs'],
|
initialEntries: ['/jobs'],
|
||||||
});
|
});
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobListItem job={mockJob} isSelected onSelect={() => {}} />,
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
{ context: { router: { history } } }
|
{ context: { router: { history } } }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
|
|||||||
|
|
||||||
test('launch button hidden from users without launch capabilities', () => {
|
test('launch button hidden from users without launch capabilities', () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobListItem
|
<table>
|
||||||
job={{
|
<tbody>
|
||||||
...mockJob,
|
<JobListItem
|
||||||
summary_fields: { user_capabilities: { start: false } },
|
job={{
|
||||||
}}
|
...mockJob,
|
||||||
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
summary_fields: { user_capabilities: { start: false } },
|
||||||
onSelect={() => {}}
|
}}
|
||||||
isSelected={false}
|
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
||||||
/>
|
onSelect={() => {}}
|
||||||
|
isSelected={false}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('LaunchButton').length).toBe(0);
|
expect(wrapper.find('LaunchButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should hide type column when showTypeColumn is false', () => {
|
test('should hide type column when showTypeColumn is false', () => {
|
||||||
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(0);
|
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show type column when showTypeColumn is true', () => {
|
test('should show type column when showTypeColumn is true', () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<JobListItem
|
<table>
|
||||||
job={mockJob}
|
<tbody>
|
||||||
showTypeColumn
|
<JobListItem
|
||||||
isSelected
|
job={mockJob}
|
||||||
onSelect={() => {}}
|
showTypeColumn
|
||||||
/>
|
isSelected
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1);
|
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ function CredentialPasswordsStep({ launchConfig, i18n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form>
|
<Form
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{showcredentialPasswordSsh && (
|
{showcredentialPasswordSsh && (
|
||||||
<PasswordField
|
<PasswordField
|
||||||
id="launch-ssh-password"
|
id="launch-ssh-password"
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const FieldHeader = styled.div`
|
|||||||
|
|
||||||
function OtherPromptsStep({ launchConfig, i18n }) {
|
function OtherPromptsStep({ launchConfig, i18n }) {
|
||||||
return (
|
return (
|
||||||
<Form>
|
<Form
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{launchConfig.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
|
{launchConfig.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
|
||||||
{launchConfig.ask_limit_on_launch && (
|
{launchConfig.ask_limit_on_launch && (
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ function SurveyStep({ surveyConfig, i18n }) {
|
|||||||
float: NumberField,
|
float: NumberField,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Form>
|
<Form
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{surveyConfig.spec.map(question => {
|
{surveyConfig.spec.map(question => {
|
||||||
const Field = fieldTypes[question.type];
|
const Field = fieldTypes[question.type];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function Lookup(props) {
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<InputGroup onBlur={onBlur}>
|
<InputGroup onBlur={onBlur}>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Search"
|
aria-label={i18n._(t`Search`)}
|
||||||
id={id}
|
id={id}
|
||||||
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||||
variant={ButtonVariant.control}
|
variant={ButtonVariant.control}
|
||||||
@@ -138,7 +138,7 @@ function Lookup(props) {
|
|||||||
>
|
>
|
||||||
{i18n._(t`Select`)}
|
{i18n._(t`Select`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="cancel" variant="secondary" onClick={closeModal}>
|
<Button key="cancel" variant="link" onClick={closeModal}>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function NotificationListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={`items-list-item-${notification.id}`}
|
aria-labelledby={`items-list-item-${notification.id}`}
|
||||||
id={`items-list-item-${notification.id}`}
|
id={`items-list-item-${notification.id}`}
|
||||||
columns={showApprovalsToggle ? 4 : 3}
|
columns={showApprovalsToggle ? 4 : 3}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function ToolbarDeleteButton({
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
key="cancel"
|
key="cancel"
|
||||||
variant="secondary"
|
variant="link"
|
||||||
aria-label={i18n._(t`cancel delete`)}
|
aria-label={i18n._(t`cancel delete`)}
|
||||||
onClick={toggleModal}
|
onClick={toggleModal}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const ActionsGrid = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
${props => {
|
${props => {
|
||||||
const columns = '40px '.repeat(props.numActions || 1);
|
const columns = props.gridColumns || '40px '.repeat(props.numActions || 1);
|
||||||
return css`
|
return css`
|
||||||
grid-template-columns: ${columns};
|
grid-template-columns: ${columns};
|
||||||
`;
|
`;
|
||||||
@@ -17,7 +17,7 @@ const ActionsGrid = styled.div`
|
|||||||
`;
|
`;
|
||||||
ActionsGrid.displayName = 'ActionsGrid';
|
ActionsGrid.displayName = 'ActionsGrid';
|
||||||
|
|
||||||
export default function ActionsTd({ children, ...props }) {
|
export default function ActionsTd({ children, gridColumns, ...props }) {
|
||||||
const numActions = children.length || 1;
|
const numActions = children.length || 1;
|
||||||
const width = numActions * 40;
|
const width = numActions * 40;
|
||||||
return (
|
return (
|
||||||
@@ -28,7 +28,7 @@ export default function ActionsTd({ children, ...props }) {
|
|||||||
`}
|
`}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ActionsGrid numActions={numActions}>
|
<ActionsGrid numActions={numActions} gridColumns={gridColumns}>
|
||||||
{React.Children.map(children, (child, i) =>
|
{React.Children.map(children, (child, i) =>
|
||||||
React.cloneElement(child, {
|
React.cloneElement(child, {
|
||||||
column: i + 1,
|
column: i + 1,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useHistory } from 'react-router-dom';
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
import { Thead, Tr, Th as PFTh } from '@patternfly/react-table';
|
import { Thead, Tr, Th as PFTh } from '@patternfly/react-table';
|
||||||
@@ -12,7 +13,7 @@ const Th = styled(PFTh)`
|
|||||||
--pf-c-table--cell--Overflow: initial;
|
--pf-c-table--cell--Overflow: initial;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function HeaderRow({ qsConfig, children }) {
|
export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -41,25 +42,40 @@ export default function HeaderRow({ qsConfig, children }) {
|
|||||||
index: sortKey || qsConfig.defaultParams?.order_by,
|
index: sortKey || qsConfig.defaultParams?.order_by,
|
||||||
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
|
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
|
||||||
};
|
};
|
||||||
|
const idPrefix = `${qsConfig.namespace}-table-sort`;
|
||||||
|
|
||||||
// empty first Th aligns with checkboxes in table rows
|
// empty first Th aligns with checkboxes in table rows
|
||||||
return (
|
return (
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
|
{isExpandable && <Th />}
|
||||||
<Th />
|
<Th />
|
||||||
{React.Children.map(children, child =>
|
{React.Children.map(
|
||||||
React.cloneElement(child, {
|
children,
|
||||||
onSort,
|
child =>
|
||||||
sortBy,
|
child &&
|
||||||
columnIndex: child.props.sortKey,
|
React.cloneElement(child, {
|
||||||
})
|
onSort,
|
||||||
|
sortBy,
|
||||||
|
columnIndex: child.props.sortKey,
|
||||||
|
idPrefix,
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
export function HeaderCell({
|
||||||
|
sortKey,
|
||||||
|
onSort,
|
||||||
|
sortBy,
|
||||||
|
columnIndex,
|
||||||
|
idPrefix,
|
||||||
|
className,
|
||||||
|
alignRight,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
const sort = sortKey
|
const sort = sortKey
|
||||||
? {
|
? {
|
||||||
onSort: (event, key, order) => onSort(sortKey, order),
|
onSort: (event, key, order) => onSort(sortKey, order),
|
||||||
@@ -67,5 +83,14 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
|||||||
columnIndex,
|
columnIndex,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
return <Th sort={sort}>{children}</Th>;
|
return (
|
||||||
|
<Th
|
||||||
|
id={sortKey ? `${idPrefix}-${sortKey}` : null}
|
||||||
|
className={className}
|
||||||
|
sort={sort}
|
||||||
|
css={alignRight ? 'text-align: right' : null}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Th>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
|
|||||||
const cell = wrapper.find('Th').at(2);
|
const cell = wrapper.find('Th').at(2);
|
||||||
expect(cell.prop('sort')).toEqual(null);
|
expect(cell.prop('sort')).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should handle null children gracefully', async () => {
|
||||||
|
const nope = false;
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<HeaderRow qsConfig={qsConfig}>
|
||||||
|
<HeaderCell sortKey="one">One</HeaderCell>
|
||||||
|
{nope && <HeaderCell>Hidden</HeaderCell>}
|
||||||
|
<HeaderCell>Two</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = wrapper.find('Th');
|
||||||
|
expect(cells).toHaveLength(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
import React, { Fragment } from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { TableComposable, Tbody } from '@patternfly/react-table';
|
import { TableComposable, Tbody } from '@patternfly/react-table';
|
||||||
@@ -88,13 +89,13 @@ function PaginatedTable({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Content = (
|
Content = (
|
||||||
<>
|
<div css="overflow: auto">
|
||||||
{hasContentLoading && <LoadingSpinner />}
|
{hasContentLoading && <LoadingSpinner />}
|
||||||
<TableComposable aria-label={dataListLabel}>
|
<TableComposable aria-label={dataListLabel}>
|
||||||
{headerRow}
|
{headerRow}
|
||||||
<Tbody>{items.map(renderRow)}</Tbody>
|
<Tbody>{items.map(renderRow)}</Tbody>
|
||||||
</TableComposable>
|
</TableComposable>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t, Trans } from '@lingui/macro';
|
import { t, Trans } from '@lingui/macro';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { Chip, Divider } from '@patternfly/react-core';
|
import { Chip, Divider, Title } from '@patternfly/react-core';
|
||||||
import { toTitleCase } from '../../util/strings';
|
import { toTitleCase } from '../../util/strings';
|
||||||
|
|
||||||
import CredentialChip from '../CredentialChip';
|
import CredentialChip from '../CredentialChip';
|
||||||
@@ -18,9 +18,19 @@ import PromptInventorySourceDetail from './PromptInventorySourceDetail';
|
|||||||
import PromptJobTemplateDetail from './PromptJobTemplateDetail';
|
import PromptJobTemplateDetail from './PromptJobTemplateDetail';
|
||||||
import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail';
|
import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail';
|
||||||
|
|
||||||
const PromptHeader = styled.h2`
|
const PromptTitle = styled(Title)`
|
||||||
font-weight: bold;
|
margin-top: var(--pf-global--spacer--xl);
|
||||||
margin: var(--pf-global--spacer--lg) 0;
|
--pf-c-title--m-md--FontWeight: 700;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PromptDivider = styled(Divider)`
|
||||||
|
margin-top: var(--pf-global--spacer--lg);
|
||||||
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const PromptDetailList = styled(DetailList)`
|
||||||
|
padding: 0px var(--pf-global--spacer--lg);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function formatTimeout(timeout) {
|
function formatTimeout(timeout) {
|
||||||
@@ -136,9 +146,11 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
|
|||||||
|
|
||||||
{hasPromptData(launchConfig) && hasOverrides && (
|
{hasPromptData(launchConfig) && hasOverrides && (
|
||||||
<>
|
<>
|
||||||
<Divider css="margin-top: var(--pf-global--spacer--lg)" />
|
<PromptTitle headingLevel="h2">
|
||||||
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
|
{i18n._(t`Prompted Values`)}
|
||||||
<DetailList aria-label="Prompt Overrides">
|
</PromptTitle>
|
||||||
|
<PromptDivider />
|
||||||
|
<PromptDetailList aria-label={i18n._(t`Prompt Overrides`)}>
|
||||||
{launchConfig.ask_job_type_on_launch && (
|
{launchConfig.ask_job_type_on_launch && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Job Type`)}
|
label={i18n._(t`Job Type`)}
|
||||||
@@ -250,7 +262,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
|
|||||||
value={overrides.extra_vars}
|
value={overrides.extra_vars}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DetailList>
|
</PromptDetailList>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function DeleteRoleConfirmationModal({
|
|||||||
>
|
>
|
||||||
{i18n._(t`Delete`)}
|
{i18n._(t`Delete`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
<Button key="cancel" variant="link" onClick={onCancel}>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -56,7 +56,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -80,7 +80,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -212,8 +212,8 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-disabled="false"
|
aria-disabled="false"
|
||||||
class="pf-c-button pf-m-secondary"
|
class="pf-c-button pf-m-link"
|
||||||
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
data-ouia-component-id="OUIA-Generated-Button-link-1"
|
||||||
data-ouia-component-type="PF4/Button"
|
data-ouia-component-type="PF4/Button"
|
||||||
data-ouia-safe="true"
|
data-ouia-safe="true"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -239,7 +239,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
</Button>,
|
</Button>,
|
||||||
<Button
|
<Button
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -517,13 +517,13 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
|||||||
<Button
|
<Button
|
||||||
key="cancel"
|
key="cancel"
|
||||||
onClick={[Function]}
|
onClick={[Function]}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
aria-disabled={false}
|
aria-disabled={false}
|
||||||
aria-label={null}
|
aria-label={null}
|
||||||
className="pf-c-button pf-m-secondary"
|
className="pf-c-button pf-m-link"
|
||||||
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
data-ouia-component-id="OUIA-Generated-Button-link-1"
|
||||||
data-ouia-component-type="PF4/Button"
|
data-ouia-component-type="PF4/Button"
|
||||||
data-ouia-safe={true}
|
data-ouia-safe={true}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RRule, rrulestr } from 'rrule';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Chip, Title, Button } from '@patternfly/react-core';
|
import { Chip, Divider, Title, Button } from '@patternfly/react-core';
|
||||||
import { Schedule } from '../../../types';
|
import { Schedule } from '../../../types';
|
||||||
import AlertModal from '../../AlertModal';
|
import AlertModal from '../../AlertModal';
|
||||||
import { CardBody, CardActionsRow } from '../../Card';
|
import { CardBody, CardActionsRow } from '../../Card';
|
||||||
@@ -27,11 +27,21 @@ import ErrorDetail from '../../ErrorDetail';
|
|||||||
import ChipGroup from '../../ChipGroup';
|
import ChipGroup from '../../ChipGroup';
|
||||||
import { VariablesDetail } from '../../CodeMirrorInput';
|
import { VariablesDetail } from '../../CodeMirrorInput';
|
||||||
|
|
||||||
|
const PromptDivider = styled(Divider)`
|
||||||
|
margin-top: var(--pf-global--spacer--lg);
|
||||||
|
margin-bottom: var(--pf-global--spacer--lg);
|
||||||
|
`;
|
||||||
|
|
||||||
const PromptTitle = styled(Title)`
|
const PromptTitle = styled(Title)`
|
||||||
|
margin-top: 40px;
|
||||||
--pf-c-title--m-md--FontWeight: 700;
|
--pf-c-title--m-md--FontWeight: 700;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const PromptDetailList = styled(DetailList)`
|
||||||
|
padding: 0px 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
function ScheduleDetail({ schedule, i18n }) {
|
function ScheduleDetail({ schedule, i18n }) {
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@@ -41,6 +51,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
dtend,
|
dtend,
|
||||||
dtstart,
|
dtstart,
|
||||||
extra_data,
|
extra_data,
|
||||||
|
inventory,
|
||||||
job_tags,
|
job_tags,
|
||||||
job_type,
|
job_type,
|
||||||
limit,
|
limit,
|
||||||
@@ -52,12 +63,21 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
skip_tags,
|
skip_tags,
|
||||||
summary_fields,
|
summary_fields,
|
||||||
timezone,
|
timezone,
|
||||||
|
verbosity,
|
||||||
} = schedule;
|
} = schedule;
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
|
const pathRoot = pathname.substr(0, pathname.indexOf('schedules'));
|
||||||
|
|
||||||
|
const VERBOSITY = {
|
||||||
|
0: i18n._(t`0 (Normal)`),
|
||||||
|
1: i18n._(t`1 (Verbose)`),
|
||||||
|
2: i18n._(t`2 (More Verbose)`),
|
||||||
|
3: i18n._(t`3 (Debug)`),
|
||||||
|
4: i18n._(t`4 (Connection Debug)`),
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
request: deleteSchedule,
|
request: deleteSchedule,
|
||||||
isLoading: isDeleteLoading,
|
isLoading: isDeleteLoading,
|
||||||
@@ -140,18 +160,34 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
survey_enabled,
|
survey_enabled,
|
||||||
} = launchData || {};
|
} = launchData || {};
|
||||||
|
|
||||||
|
const showCredentialsDetail =
|
||||||
|
ask_credential_on_launch && credentials.length > 0;
|
||||||
|
const showInventoryDetail = ask_inventory_on_launch && inventory;
|
||||||
|
const showVariablesDetail =
|
||||||
|
(ask_variables_on_launch || survey_enabled) &&
|
||||||
|
((typeof extra_data === 'string' && extra_data !== '') ||
|
||||||
|
(typeof extra_data === 'object' && Object.keys(extra_data).length > 0));
|
||||||
|
const showTagsDetail = ask_tags_on_launch && job_tags && job_tags.length > 0;
|
||||||
|
const showSkipTagsDetail =
|
||||||
|
ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0;
|
||||||
|
const showDiffModeDetail =
|
||||||
|
ask_diff_mode_on_launch && typeof diff_mode === 'boolean';
|
||||||
|
const showLimitDetail = ask_limit_on_launch && limit;
|
||||||
|
const showJobTypeDetail = ask_job_type_on_launch && job_type;
|
||||||
|
const showSCMBranchDetail = ask_scm_branch_on_launch && scm_branch;
|
||||||
|
const showVerbosityDetail = ask_verbosity_on_launch && VERBOSITY[verbosity];
|
||||||
|
|
||||||
const showPromptedFields =
|
const showPromptedFields =
|
||||||
ask_credential_on_launch ||
|
showCredentialsDetail ||
|
||||||
ask_diff_mode_on_launch ||
|
showDiffModeDetail ||
|
||||||
ask_inventory_on_launch ||
|
showInventoryDetail ||
|
||||||
ask_job_type_on_launch ||
|
showJobTypeDetail ||
|
||||||
ask_limit_on_launch ||
|
showLimitDetail ||
|
||||||
ask_scm_branch_on_launch ||
|
showSCMBranchDetail ||
|
||||||
ask_skip_tags_on_launch ||
|
showSkipTagsDetail ||
|
||||||
ask_tags_on_launch ||
|
showTagsDetail ||
|
||||||
ask_variables_on_launch ||
|
showVerbosityDetail ||
|
||||||
ask_verbosity_on_launch ||
|
showVariablesDetail;
|
||||||
survey_enabled;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <ContentLoading />;
|
return <ContentLoading />;
|
||||||
@@ -189,15 +225,18 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
date={modified}
|
date={modified}
|
||||||
user={summary_fields.modified_by}
|
user={summary_fields.modified_by}
|
||||||
/>
|
/>
|
||||||
{showPromptedFields && (
|
</DetailList>
|
||||||
<>
|
{showPromptedFields && (
|
||||||
<PromptTitle headingLevel="h2">
|
<>
|
||||||
{i18n._(t`Prompted Fields`)}
|
<PromptTitle headingLevel="h2">
|
||||||
</PromptTitle>
|
{i18n._(t`Prompted Values`)}
|
||||||
|
</PromptTitle>
|
||||||
|
<PromptDivider />
|
||||||
|
<PromptDetailList>
|
||||||
{ask_job_type_on_launch && (
|
{ask_job_type_on_launch && (
|
||||||
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
||||||
)}
|
)}
|
||||||
{ask_inventory_on_launch && (
|
{showInventoryDetail && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Inventory`)}
|
label={i18n._(t`Inventory`)}
|
||||||
value={
|
value={
|
||||||
@@ -226,13 +265,19 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
{ask_limit_on_launch && (
|
{ask_limit_on_launch && (
|
||||||
<Detail label={i18n._(t`Limit`)} value={limit} />
|
<Detail label={i18n._(t`Limit`)} value={limit} />
|
||||||
)}
|
)}
|
||||||
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
|
{ask_verbosity_on_launch && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Verbosity`)}
|
||||||
|
value={VERBOSITY[verbosity]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDiffModeDetail && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Show Changes`)}
|
label={i18n._(t`Show Changes`)}
|
||||||
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
|
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ask_credential_on_launch && (
|
{showCredentialsDetail && (
|
||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n._(t`Credentials`)}
|
label={i18n._(t`Credentials`)}
|
||||||
@@ -245,7 +290,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ask_tags_on_launch && job_tags && job_tags.length > 0 && (
|
{showTagsDetail && (
|
||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n._(t`Job Tags`)}
|
label={i18n._(t`Job Tags`)}
|
||||||
@@ -263,7 +308,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0 && (
|
{showSkipTagsDetail && (
|
||||||
<Detail
|
<Detail
|
||||||
fullWidth
|
fullWidth
|
||||||
label={i18n._(t`Skip Tags`)}
|
label={i18n._(t`Skip Tags`)}
|
||||||
@@ -281,16 +326,16 @@ function ScheduleDetail({ schedule, i18n }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(ask_variables_on_launch || survey_enabled) && (
|
{showVariablesDetail && (
|
||||||
<VariablesDetail
|
<VariablesDetail
|
||||||
value={extra_data}
|
value={extra_data}
|
||||||
rows={4}
|
rows={4}
|
||||||
label={i18n._(t`Variables`)}
|
label={i18n._(t`Variables`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</PromptDetailList>
|
||||||
)}
|
</>
|
||||||
</DetailList>
|
)}
|
||||||
<CardActionsRow>
|
<CardActionsRow>
|
||||||
{summary_fields?.user_capabilities?.edit && (
|
{summary_fields?.user_capabilities?.edit && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -73,10 +73,6 @@ const schedule = {
|
|||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
},
|
},
|
||||||
inventory: {
|
|
||||||
id: 1,
|
|
||||||
name: 'Test Inventory',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
created: '2020-03-03T20:38:54.210306Z',
|
created: '2020-03-03T20:38:54.210306Z',
|
||||||
modified: '2020-03-03T20:38:54.210336Z',
|
modified: '2020-03-03T20:38:54.210336Z',
|
||||||
@@ -88,6 +84,27 @@ const schedule = {
|
|||||||
dtend: '2020-07-06T04:00:00Z',
|
dtend: '2020-07-06T04:00:00Z',
|
||||||
next_run: '2020-03-16T04:00:00Z',
|
next_run: '2020-03-16T04:00:00Z',
|
||||||
extra_data: {},
|
extra_data: {},
|
||||||
|
inventory: null,
|
||||||
|
scm_branch: null,
|
||||||
|
job_type: null,
|
||||||
|
job_tags: null,
|
||||||
|
skip_tags: null,
|
||||||
|
limit: null,
|
||||||
|
diff_mode: null,
|
||||||
|
verbosity: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleWithPrompts = {
|
||||||
|
...schedule,
|
||||||
|
job_type: 'run',
|
||||||
|
inventory: 1,
|
||||||
|
job_tags: 'tag1',
|
||||||
|
skip_tags: 'tag2',
|
||||||
|
scm_branch: 'foo/branch',
|
||||||
|
limit: 'localhost',
|
||||||
|
diff_mode: true,
|
||||||
|
verbosity: 1,
|
||||||
|
extra_data: { foo: 'fii' },
|
||||||
};
|
};
|
||||||
|
|
||||||
SchedulesAPI.createPreview.mockResolvedValue({
|
SchedulesAPI.createPreview.mockResolvedValue({
|
||||||
@@ -159,13 +176,14 @@ describe('<ScheduleDetail />', () => {
|
|||||||
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
|
||||||
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(0);
|
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
|
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
|
||||||
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
|
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
|
||||||
@@ -189,18 +207,6 @@ describe('<ScheduleDetail />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
|
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
|
||||||
const scheduleWithPrompts = {
|
|
||||||
...schedule,
|
|
||||||
job_type: 'run',
|
|
||||||
inventory: 1,
|
|
||||||
job_tags: 'tag1',
|
|
||||||
skip_tags: 'tag2',
|
|
||||||
scm_branch: 'foo/branch',
|
|
||||||
limit: 'localhost',
|
|
||||||
diff_mode: true,
|
|
||||||
verbosity: 1,
|
|
||||||
extra_data: { foo: 'fii' },
|
|
||||||
};
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<Route
|
<Route
|
||||||
@@ -245,7 +251,7 @@ describe('<ScheduleDetail />', () => {
|
|||||||
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Repeat Frequency"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Created"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Last Modified"]').length).toBe(1);
|
||||||
expect(wrapper.find('Title[children="Prompted Fields"]').length).toBe(1);
|
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('Detail[label="Job Type"]')
|
.find('Detail[label="Job Type"]')
|
||||||
@@ -265,12 +271,102 @@ describe('<ScheduleDetail />', () => {
|
|||||||
.find('dd')
|
.find('dd')
|
||||||
.text()
|
.text()
|
||||||
).toBe('localhost');
|
).toBe('localhost');
|
||||||
|
expect(
|
||||||
|
wrapper
|
||||||
|
.find('Detail[label="Verbosity"]')
|
||||||
|
.find('dd')
|
||||||
|
.text()
|
||||||
|
).toBe('1 (Verbose)');
|
||||||
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
|
||||||
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
|
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
|
||||||
expect(wrapper.find('VariablesDetail').length).toBe(1);
|
expect(wrapper.find('VariablesDetail').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
test('prompt values section should be hidden if no overrides are present on the schedule but ask_ options are all true', async () => {
|
||||||
|
SchedulesAPI.readCredentials.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Route
|
||||||
|
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||||
|
component={() => <ScheduleDetail schedule={schedule} />}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
route: {
|
||||||
|
location: history.location,
|
||||||
|
match: { params: { id: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('VariablesDetail').length).toBe(0);
|
||||||
|
});
|
||||||
|
test('prompt values section should be hidden if overrides are present on the schedule but ask_ options are all false', async () => {
|
||||||
|
SchedulesAPI.readCredentials.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
count: 0,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
|
||||||
|
await act(async () => {
|
||||||
|
wrapper = mountWithContexts(
|
||||||
|
<Route
|
||||||
|
path="/templates/job_template/:id/schedules/:scheduleId"
|
||||||
|
component={() => <ScheduleDetail schedule={scheduleWithPrompts} />}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
router: {
|
||||||
|
history,
|
||||||
|
route: {
|
||||||
|
location: history.location,
|
||||||
|
match: { params: { id: 1 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||||
|
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Job Type"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Inventory"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Source Control Branch"]').length).toBe(
|
||||||
|
0
|
||||||
|
);
|
||||||
|
expect(wrapper.find('Detail[label="Limit"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Verbosity"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Show Changes"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
|
||||||
|
expect(wrapper.find('VariablesDetail').length).toBe(0);
|
||||||
|
});
|
||||||
test('error shown when error encountered fetching credentials', async () => {
|
test('error shown when error encountered fetching credentials', async () => {
|
||||||
SchedulesAPI.readCredentials.mockRejectedValueOnce(
|
SchedulesAPI.readCredentials.mockRejectedValueOnce(
|
||||||
new Error({
|
new Error({
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { t } from '@lingui/macro';
|
|||||||
import { SchedulesAPI } from '../../../api';
|
import { SchedulesAPI } from '../../../api';
|
||||||
import AlertModal from '../../AlertModal';
|
import AlertModal from '../../AlertModal';
|
||||||
import ErrorDetail from '../../ErrorDetail';
|
import ErrorDetail from '../../ErrorDetail';
|
||||||
|
import PaginatedTable, { HeaderRow, HeaderCell } from '../../PaginatedTable';
|
||||||
import DataListToolbar from '../../DataListToolbar';
|
import DataListToolbar from '../../DataListToolbar';
|
||||||
import PaginatedDataList, {
|
import { ToolbarAddButton, ToolbarDeleteButton } from '../../PaginatedDataList';
|
||||||
ToolbarAddButton,
|
|
||||||
ToolbarDeleteButton,
|
|
||||||
} from '../../PaginatedDataList';
|
|
||||||
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 ScheduleListItem from './ScheduleListItem';
|
import ScheduleListItem from './ScheduleListItem';
|
||||||
@@ -119,19 +117,28 @@ function ScheduleList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading}
|
||||||
items={schedules}
|
items={schedules}
|
||||||
itemCount={itemCount}
|
itemCount={itemCount}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={QS_CONFIG}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
renderItem={item => (
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
||||||
|
<HeaderCell sortKey="next_run">{i18n._(t`Next Run`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
|
renderRow={(item, index) => (
|
||||||
<ScheduleListItem
|
<ScheduleListItem
|
||||||
isSelected={selected.some(row => row.id === item.id)}
|
isSelected={selected.some(row => row.id === item.id)}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onSelect={() => handleSelect(item)}
|
onSelect={() => handleSelect(item)}
|
||||||
schedule={item}
|
schedule={item}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
@@ -153,16 +160,6 @@ function ScheduleList({
|
|||||||
key: 'modified_by__username__icontains',
|
key: 'modified_by__username__icontains',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Next Run`),
|
|
||||||
key: 'next_run',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
|
|||||||
@@ -59,44 +59,61 @@ describe('ScheduleList', () => {
|
|||||||
|
|
||||||
test('should check and uncheck the row item', async () => {
|
test('should check and uncheck the row item', async () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-schedule-1"]')
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')(true);
|
.invoke('onChange')(true);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-schedule-1"]')
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')(false);
|
.invoke('onChange')(false);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should check all row items when select all is checked', async () => {
|
test('should check all row items when select all is checked', async () => {
|
||||||
wrapper.find('DataListCheck').forEach(el => {
|
expect(wrapper.find('.pf-c-table__check input')).toHaveLength(5);
|
||||||
|
wrapper.find('.pf-c-table__check input').forEach(el => {
|
||||||
expect(el.props().checked).toBe(false);
|
expect(el.props().checked).toBe(false);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
wrapper.find('DataListCheck').forEach(el => {
|
wrapper.find('.pf-c-table__check input').forEach(el => {
|
||||||
expect(el.props().checked).toBe(true);
|
expect(el.props().checked).toBe(true);
|
||||||
});
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
wrapper.find('DataListCheck').forEach(el => {
|
wrapper.find('.pf-c-table__check input').forEach(el => {
|
||||||
expect(el.props().checked).toBe(false);
|
expect(el.props().checked).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -104,7 +121,8 @@ describe('ScheduleList', () => {
|
|||||||
test('should call api delete schedules for each selected schedule', async () => {
|
test('should call api delete schedules for each selected schedule', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-schedule-3"]')
|
.find('.pf-c-table__check input')
|
||||||
|
.at(3)
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
@@ -122,7 +140,8 @@ describe('ScheduleList', () => {
|
|||||||
expect(wrapper.find('Modal').length).toBe(0);
|
expect(wrapper.find('Modal').length).toBe(0);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-schedule-2"]')
|
.find('.pf-c-table__check input')
|
||||||
|
.at(2)
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|||||||
@@ -4,31 +4,16 @@ import { bool, func } from 'prop-types';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Button } from '@patternfly/react-core';
|
||||||
Button,
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemRow,
|
|
||||||
DataListItemCells,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
|
||||||
import DataListCell from '../../DataListCell';
|
|
||||||
import { DetailList, Detail } from '../../DetailList';
|
import { DetailList, Detail } from '../../DetailList';
|
||||||
|
import { ActionsTd, ActionItem } from '../../PaginatedTable';
|
||||||
import { ScheduleToggle } from '..';
|
import { ScheduleToggle } from '..';
|
||||||
import { Schedule } from '../../../types';
|
import { Schedule } from '../../../types';
|
||||||
import { formatDateString } from '../../../util/dates';
|
import { formatDateString } from '../../../util/dates';
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: 92px 40px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
|
|
||||||
const labelId = `check-action-${schedule.id}`;
|
const labelId = `check-action-${schedule.id}`;
|
||||||
|
|
||||||
const jobTypeLabels = {
|
const jobTypeLabels = {
|
||||||
@@ -62,69 +47,56 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<Tr id={`schedule-row-${schedule.id}`}>
|
||||||
key={schedule.id}
|
<Td
|
||||||
aria-labelledby={labelId}
|
select={{
|
||||||
id={`${schedule.id}`}
|
rowIndex,
|
||||||
>
|
isSelected,
|
||||||
<DataListItemRow>
|
onSelect,
|
||||||
<DataListCheck
|
disable: false,
|
||||||
id={`select-schedule-${schedule.id}`}
|
}}
|
||||||
checked={isSelected}
|
dataLabel={i18n._(t`Selected`)}
|
||||||
onChange={onSelect}
|
/>
|
||||||
aria-labelledby={labelId}
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
/>
|
<Link to={`${scheduleBaseUrl}/details`}>
|
||||||
<DataListItemCells
|
<b>{schedule.name}</b>
|
||||||
dataListCells={[
|
</Link>
|
||||||
<DataListCell key="name">
|
</Td>
|
||||||
<Link to={`${scheduleBaseUrl}/details`}>
|
<Td dataLabel={i18n._(t`Type`)}>
|
||||||
<b>{schedule.name}</b>
|
{
|
||||||
</Link>
|
jobTypeLabels[
|
||||||
</DataListCell>,
|
schedule.summary_fields.unified_job_template.unified_job_type
|
||||||
<DataListCell key="type">
|
]
|
||||||
{
|
}
|
||||||
jobTypeLabels[
|
</Td>
|
||||||
schedule.summary_fields.unified_job_template.unified_job_type
|
<Td dataLabel={i18n._(t`Next Run`)}>
|
||||||
]
|
{schedule.next_run && (
|
||||||
}
|
<DetailList stacked>
|
||||||
</DataListCell>,
|
<Detail
|
||||||
<DataListCell key="next_run">
|
label={i18n._(t`Next Run`)}
|
||||||
{schedule.next_run && (
|
value={formatDateString(schedule.next_run)}
|
||||||
<DetailList stacked>
|
/>
|
||||||
<Detail
|
</DetailList>
|
||||||
label={i18n._(t`Next Run`)}
|
)}
|
||||||
value={formatDateString(schedule.next_run)}
|
</Td>
|
||||||
/>
|
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
|
||||||
</DetailList>
|
<ScheduleToggle schedule={schedule} />
|
||||||
)}
|
<ActionItem
|
||||||
</DataListCell>,
|
visible={schedule.summary_fields.user_capabilities.edit}
|
||||||
]}
|
tooltip={i18n._(t`Edit Schedule`)}
|
||||||
/>
|
|
||||||
<DataListAction
|
|
||||||
aria-label="actions"
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
id={labelId}
|
|
||||||
key="actions"
|
|
||||||
>
|
>
|
||||||
<ScheduleToggle schedule={schedule} />
|
<Button
|
||||||
{schedule.summary_fields.user_capabilities.edit ? (
|
aria-label={i18n._(t`Edit Schedule`)}
|
||||||
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
|
css="grid-column: 2"
|
||||||
<Button
|
variant="plain"
|
||||||
aria-label={i18n._(t`Edit Schedule`)}
|
component={Link}
|
||||||
css="grid-column: 2"
|
to={`${scheduleBaseUrl}/edit`}
|
||||||
variant="plain"
|
>
|
||||||
component={Link}
|
<PencilAltIcon />
|
||||||
to={`${scheduleBaseUrl}/edit`}
|
</Button>
|
||||||
>
|
</ActionItem>
|
||||||
<PencilAltIcon />
|
</ActionsTd>
|
||||||
</Button>
|
</Tr>
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
|
||||||
</DataListItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,39 +50,47 @@ describe('ScheduleListItem', () => {
|
|||||||
describe('User has edit permissions', () => {
|
describe('User has edit permissions', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ScheduleListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
onSelect={onSelect}
|
<ScheduleListItem
|
||||||
schedule={mockSchedule}
|
isSelected={false}
|
||||||
/>
|
onSelect={onSelect}
|
||||||
|
schedule={mockSchedule}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Name correctly shown with correct link', () => {
|
test('Name correctly shown with correct link', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.first()
|
.at(1)
|
||||||
.text()
|
.text()
|
||||||
).toBe('Mock Schedule');
|
).toBe('Mock Schedule');
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.first()
|
.at(1)
|
||||||
.find('Link')
|
.find('Link')
|
||||||
.props().to
|
.props().to
|
||||||
).toBe('/templates/job_template/12/schedules/6/details');
|
).toBe('/templates/job_template/12/schedules/6/details');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Type correctly shown', () => {
|
test('Type correctly shown', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.at(1)
|
.at(2)
|
||||||
.text()
|
.text()
|
||||||
).toBe('Playbook Run');
|
).toBe('Playbook Run');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Edit button shown with correct link', () => {
|
test('Edit button shown with correct link', () => {
|
||||||
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
@@ -92,6 +100,7 @@ describe('ScheduleListItem', () => {
|
|||||||
.props().to
|
.props().to
|
||||||
).toBe('/templates/job_template/12/schedules/6/edit');
|
).toBe('/templates/job_template/12/schedules/6/edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Toggle button enabled', () => {
|
test('Toggle button enabled', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
@@ -100,63 +109,74 @@ describe('ScheduleListItem', () => {
|
|||||||
.props().isDisabled
|
.props().isDisabled
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
test('Clicking checkbox makes expected callback', () => {
|
|
||||||
|
test('Clicking checkbox selects item', () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck')
|
.find('Td')
|
||||||
.first()
|
.first()
|
||||||
.find('input')
|
.find('input')
|
||||||
.simulate('change');
|
.simulate('change');
|
||||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('User has read-only permissions', () => {
|
describe('User has read-only permissions', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ScheduleListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
onSelect={onSelect}
|
<ScheduleListItem
|
||||||
schedule={{
|
isSelected={false}
|
||||||
...mockSchedule,
|
onSelect={onSelect}
|
||||||
summary_fields: {
|
schedule={{
|
||||||
...mockSchedule.summary_fields,
|
...mockSchedule,
|
||||||
user_capabilities: {
|
summary_fields: {
|
||||||
edit: false,
|
...mockSchedule.summary_fields,
|
||||||
delete: false,
|
user_capabilities: {
|
||||||
},
|
edit: false,
|
||||||
},
|
delete: false,
|
||||||
}}
|
},
|
||||||
/>
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Name correctly shown with correct link', () => {
|
test('Name correctly shown with correct link', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.first()
|
.at(1)
|
||||||
.text()
|
.text()
|
||||||
).toBe('Mock Schedule');
|
).toBe('Mock Schedule');
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.first()
|
.at(1)
|
||||||
.find('Link')
|
.find('Link')
|
||||||
.props().to
|
.props().to
|
||||||
).toBe('/templates/job_template/12/schedules/6/details');
|
).toBe('/templates/job_template/12/schedules/6/details');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Type correctly shown', () => {
|
test('Type correctly shown', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCell')
|
.find('Td')
|
||||||
.at(1)
|
.at(2)
|
||||||
.text()
|
.text()
|
||||||
).toBe('Playbook Run');
|
).toBe('Playbook Run');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Edit button hidden', () => {
|
test('Edit button hidden', () => {
|
||||||
expect(wrapper.find('PencilAltIcon').length).toBe(0);
|
expect(wrapper.find('PencilAltIcon').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Toggle button disabled', () => {
|
test('Toggle button disabled', () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper
|
wrapper
|
||||||
|
|||||||
@@ -7,29 +7,33 @@ import {
|
|||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
} from '../../../api';
|
} from '../../api';
|
||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../AlertModal';
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
import DatalistToolbar from '../DataListToolbar';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../ErrorDetail';
|
||||||
import PaginatedDataList, {
|
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||||
ToolbarDeleteButton,
|
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||||
} from '../../../components/PaginatedDataList';
|
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 useWsTemplates from '../../util/useWsTemplates';
|
||||||
import useWsTemplates from '../../../util/useWsTemplates';
|
import AddDropDownButton from '../AddDropDownButton';
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
|
||||||
import TemplateListItem from './TemplateListItem';
|
import TemplateListItem from './TemplateListItem';
|
||||||
|
|
||||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
function TemplateList({ defaultParams, i18n }) {
|
||||||
// workflow_job_template so the params sent to the API match what the api expects.
|
// The type value in const qsConfig below does not have a space between job_template and
|
||||||
const QS_CONFIG = getQSConfig('template', {
|
// workflow_job_template so the params sent to the API match what the api expects.
|
||||||
page: 1,
|
const qsConfig = getQSConfig(
|
||||||
page_size: 20,
|
'template',
|
||||||
order_by: 'name',
|
{
|
||||||
type: 'job_template,workflow_job_template',
|
page: 1,
|
||||||
});
|
page_size: 20,
|
||||||
|
order_by: 'name',
|
||||||
|
type: 'job_template,workflow_job_template',
|
||||||
|
...defaultParams,
|
||||||
|
},
|
||||||
|
['id', 'page', 'page_size']
|
||||||
|
);
|
||||||
|
|
||||||
function TemplateList({ i18n }) {
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ function TemplateList({ i18n }) {
|
|||||||
request: fetchTemplates,
|
request: fetchTemplates,
|
||||||
} = useRequest(
|
} = useRequest(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
const params = parseQueryString(qsConfig, location.search);
|
||||||
const responses = await Promise.all([
|
const responses = await Promise.all([
|
||||||
UnifiedJobTemplatesAPI.read(params),
|
UnifiedJobTemplatesAPI.read(params),
|
||||||
JobTemplatesAPI.readOptions(),
|
JobTemplatesAPI.readOptions(),
|
||||||
@@ -66,7 +70,7 @@ function TemplateList({ i18n }) {
|
|||||||
responses[3].data.actions?.GET || {}
|
responses[3].data.actions?.GET || {}
|
||||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
||||||
};
|
};
|
||||||
}, [location]),
|
}, [location]), // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
{
|
{
|
||||||
results: [],
|
results: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -105,7 +109,7 @@ function TemplateList({ i18n }) {
|
|||||||
);
|
);
|
||||||
}, [selected]),
|
}, [selected]),
|
||||||
{
|
{
|
||||||
qsConfig: QS_CONFIG,
|
qsConfig,
|
||||||
allItemsSelected: isAllSelected,
|
allItemsSelected: isAllSelected,
|
||||||
fetchItems: fetchTemplates,
|
fetchItems: fetchTemplates,
|
||||||
}
|
}
|
||||||
@@ -167,13 +171,13 @@ function TemplateList({ i18n }) {
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading}
|
||||||
items={templates}
|
items={templates}
|
||||||
itemCount={count}
|
itemCount={count}
|
||||||
pluralizedItemName={i18n._(t`Templates`)}
|
pluralizedItemName={i18n._(t`Templates`)}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
onRowClick={handleSelect}
|
onRowClick={handleSelect}
|
||||||
toolbarSearchColumns={[
|
toolbarSearchColumns={[
|
||||||
{
|
{
|
||||||
@@ -206,53 +210,37 @@ function TemplateList({ i18n }) {
|
|||||||
key: 'modified_by__username__icontains',
|
key: 'modified_by__username__icontains',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Inventory`),
|
|
||||||
key: 'job_template__inventory__id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Last Job Run`),
|
|
||||||
key: 'last_job_run',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Modified`),
|
|
||||||
key: 'modified',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Project`),
|
|
||||||
key: 'jobtemplate__project__id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Type`),
|
|
||||||
key: 'type',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={qsConfig} isExpandable>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell sortKey="type">{i18n._(t`Type`)}</HeaderCell>
|
||||||
|
<HeaderCell sortKey="last_job_run">
|
||||||
|
{i18n._(t`Last Ran`)}
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DatalistToolbar
|
<DatalistToolbar
|
||||||
{...props}
|
{...props}
|
||||||
showSelectAll
|
showSelectAll
|
||||||
isAllSelected={isAllSelected}
|
isAllSelected={isAllSelected}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
qsConfig={QS_CONFIG}
|
qsConfig={qsConfig}
|
||||||
additionalControls={[
|
additionalControls={[
|
||||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
...(canAddJT || canAddWFJT ? [addButton] : []),
|
||||||
<ToolbarDeleteButton
|
<ToolbarDeleteButton
|
||||||
key="delete"
|
key="delete"
|
||||||
onDelete={handleTemplateDelete}
|
onDelete={handleTemplateDelete}
|
||||||
itemsToDelete={selected}
|
itemsToDelete={selected}
|
||||||
pluralizedItemName="Templates"
|
pluralizedItemName={i18n._(t`Templates`)}
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={template => (
|
renderRow={(template, index) => (
|
||||||
<TemplateListItem
|
<TemplateListItem
|
||||||
key={template.id}
|
key={template.id}
|
||||||
value={template.name}
|
value={template.name}
|
||||||
@@ -261,6 +249,7 @@ function TemplateList({ i18n }) {
|
|||||||
onSelect={() => handleSelect(template)}
|
onSelect={() => handleSelect(template)}
|
||||||
isSelected={selected.some(row => row.id === template.id)}
|
isSelected={selected.some(row => row.id === template.id)}
|
||||||
fetchTemplates={fetchTemplates}
|
fetchTemplates={fetchTemplates}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
JobTemplatesAPI,
|
JobTemplatesAPI,
|
||||||
UnifiedJobTemplatesAPI,
|
UnifiedJobTemplatesAPI,
|
||||||
WorkflowJobTemplatesAPI,
|
WorkflowJobTemplatesAPI,
|
||||||
} from '../../../api';
|
} from '../../api';
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
} from '../../../testUtils/enzymeHelpers';
|
||||||
|
|
||||||
import TemplateList from './TemplateList';
|
import TemplateList from './TemplateList';
|
||||||
|
|
||||||
jest.mock('../../../api');
|
jest.mock('../../api');
|
||||||
|
|
||||||
const mockTemplates = [
|
const mockTemplates = [
|
||||||
{
|
{
|
||||||
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import 'styled-components/macro';
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Tooltip, Chip } from '@patternfly/react-core';
|
||||||
|
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import {
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
PencilAltIcon,
|
||||||
|
ProjectDiagramIcon,
|
||||||
|
RocketIcon,
|
||||||
|
} from '@patternfly/react-icons';
|
||||||
|
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||||
|
import { DetailList, Detail, DeletedDetail } from '../DetailList';
|
||||||
|
import ChipGroup from '../ChipGroup';
|
||||||
|
import CredentialChip from '../CredentialChip';
|
||||||
|
import { timeOfDay, formatDateString } from '../../util/dates';
|
||||||
|
|
||||||
|
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
|
||||||
|
import LaunchButton from '../LaunchButton';
|
||||||
|
import Sparkline from '../Sparkline';
|
||||||
|
import { toTitleCase } from '../../util/strings';
|
||||||
|
import CopyButton from '../CopyButton';
|
||||||
|
|
||||||
|
function TemplateListItem({
|
||||||
|
i18n,
|
||||||
|
template,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
detailUrl,
|
||||||
|
fetchTemplates,
|
||||||
|
rowIndex,
|
||||||
|
}) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
const labelId = `check-action-${template.id}`;
|
||||||
|
|
||||||
|
const copyTemplate = useCallback(async () => {
|
||||||
|
if (template.type === 'job_template') {
|
||||||
|
await JobTemplatesAPI.copy(template.id, {
|
||||||
|
name: `${template.name} @ ${timeOfDay()}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||||
|
name: `${template.name} @ ${timeOfDay()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await fetchTemplates();
|
||||||
|
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||||
|
|
||||||
|
const handleCopyStart = useCallback(() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyFinish = useCallback(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
summary_fields: summaryFields,
|
||||||
|
ask_inventory_on_launch: askInventoryOnLaunch,
|
||||||
|
} = template;
|
||||||
|
|
||||||
|
const missingResourceIcon =
|
||||||
|
template.type === 'job_template' &&
|
||||||
|
(!summaryFields.project ||
|
||||||
|
(!summaryFields.inventory && !askInventoryOnLaunch));
|
||||||
|
|
||||||
|
const inventoryValue = (kind, id) => {
|
||||||
|
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||||
|
|
||||||
|
return askInventoryOnLaunch ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||||
|
{summaryFields.inventory.name}
|
||||||
|
</Link>
|
||||||
|
<span> {i18n._(t`(Prompt on launch)`)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||||
|
{summaryFields.inventory.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let lastRun = '';
|
||||||
|
const mostRecentJob = template.summary_fields.recent_jobs
|
||||||
|
? template.summary_fields.recent_jobs[0]
|
||||||
|
: null;
|
||||||
|
if (mostRecentJob) {
|
||||||
|
lastRun = mostRecentJob.finished
|
||||||
|
? formatDateString(mostRecentJob.finished)
|
||||||
|
: i18n._(t`Running`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr id={`template-row-${template.id}`}>
|
||||||
|
<Td
|
||||||
|
expand={{
|
||||||
|
rowIndex,
|
||||||
|
isExpanded,
|
||||||
|
onToggle: () => setIsExpanded(!isExpanded),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Td
|
||||||
|
select={{
|
||||||
|
rowIndex,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}}
|
||||||
|
dataLabel={i18n._(t`Selected`)}
|
||||||
|
/>
|
||||||
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
|
<Link to={`${detailUrl}`}>
|
||||||
|
<b>{template.name}</b>
|
||||||
|
</Link>
|
||||||
|
{missingResourceIcon && (
|
||||||
|
<span>
|
||||||
|
<Tooltip
|
||||||
|
content={i18n._(t`Resources are missing from this template.`)}
|
||||||
|
position="right"
|
||||||
|
>
|
||||||
|
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Type`)}>{toTitleCase(template.type)}</Td>
|
||||||
|
<Td dataLabel={i18n._(t`Last Ran`)}>{lastRun}</Td>
|
||||||
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.type === 'workflow_job_template'}
|
||||||
|
tooltip={i18n._(t`Visualizer`)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`template-action-visualizer-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Visualizer`)}
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||||
|
>
|
||||||
|
<ProjectDiagramIcon />
|
||||||
|
</Button>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.summary_fields.user_capabilities.start}
|
||||||
|
tooltip={i18n._(t`Launch Template`)}
|
||||||
|
>
|
||||||
|
<LaunchButton resource={template}>
|
||||||
|
{({ handleLaunch }) => (
|
||||||
|
<Button
|
||||||
|
id={`template-action-launch-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Launch template`)}
|
||||||
|
variant="plain"
|
||||||
|
onClick={handleLaunch}
|
||||||
|
>
|
||||||
|
<RocketIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</LaunchButton>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem
|
||||||
|
visible={template.summary_fields.user_capabilities.edit}
|
||||||
|
tooltip={i18n._(t`Edit Template`)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
id={`template-action-edit-${template.id}`}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={i18n._(t`Edit Template`)}
|
||||||
|
variant="plain"
|
||||||
|
component={Link}
|
||||||
|
to={`/templates/${template.type}/${template.id}/edit`}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</Button>
|
||||||
|
</ActionItem>
|
||||||
|
<ActionItem visible={template.summary_fields.user_capabilities.copy}>
|
||||||
|
<CopyButton
|
||||||
|
id={`template-action-copy-${template.id}`}
|
||||||
|
helperText={{
|
||||||
|
errorMessage: i18n._(t`Failed to copy template.`),
|
||||||
|
tooltip: i18n._(t`Copy Template`),
|
||||||
|
}}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onCopyStart={handleCopyStart}
|
||||||
|
onCopyFinish={handleCopyFinish}
|
||||||
|
copyItem={copyTemplate}
|
||||||
|
/>
|
||||||
|
</ActionItem>
|
||||||
|
</ActionsTd>
|
||||||
|
</Tr>
|
||||||
|
<Tr isExpanded={isExpanded}>
|
||||||
|
<Td colSpan={2} />
|
||||||
|
<Td colSpan={4}>
|
||||||
|
<ExpandableRowContent>
|
||||||
|
<DetailList>
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Activity`)}
|
||||||
|
value={<Sparkline jobs={summaryFields.recent_jobs} />}
|
||||||
|
dataCy={`template-${template.id}-activity`}
|
||||||
|
/>
|
||||||
|
{summaryFields.credentials && summaryFields.credentials.length && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Credentials`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={summaryFields.credentials.length}
|
||||||
|
>
|
||||||
|
{summaryFields.credentials.map(c => (
|
||||||
|
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-credentials`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summaryFields.inventory ? (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Inventory`)}
|
||||||
|
value={inventoryValue(
|
||||||
|
summaryFields.inventory.kind,
|
||||||
|
summaryFields.inventory.id
|
||||||
|
)}
|
||||||
|
dataCy={`template-${template.id}-inventory`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!askInventoryOnLaunch && (
|
||||||
|
<DeletedDetail label={i18n._(t`Inventory`)} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{summaryFields.labels && summaryFields.labels.results.length > 0 && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Labels`)}
|
||||||
|
value={
|
||||||
|
<ChipGroup
|
||||||
|
numChips={5}
|
||||||
|
totalChips={summaryFields.labels.results.length}
|
||||||
|
>
|
||||||
|
{summaryFields.labels.results.map(l => (
|
||||||
|
<Chip key={l.id} isReadOnly>
|
||||||
|
{l.name}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</ChipGroup>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-labels`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{summaryFields.project && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Project`)}
|
||||||
|
value={
|
||||||
|
<Link to={`/projects/${summaryFields.project.id}/details`}>
|
||||||
|
{summaryFields.project.name}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
dataCy={`template-${template.id}-project`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Last Modified`)}
|
||||||
|
value={formatDateString(template.modified)}
|
||||||
|
dataCy={`template-${template.id}-last-modified`}
|
||||||
|
/>
|
||||||
|
</DetailList>
|
||||||
|
</ExpandableRowContent>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TemplateListItem as _TemplateListItem };
|
||||||
|
export default withI18n()(TemplateListItem);
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||||
|
import { JobTemplatesAPI } from '../../api';
|
||||||
|
import mockJobTemplateData from './data.job_template.json';
|
||||||
|
import TemplateListItem from './TemplateListItem';
|
||||||
|
|
||||||
|
jest.mock('../../api');
|
||||||
|
|
||||||
|
describe('<TemplateListItem />', () => {
|
||||||
|
test('launch button shown to users with start capabilities', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
start: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('launch button hidden from users without start capabilities', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
start: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('edit button shown to users with edit capabilities', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('missing resource icon is shown.', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
test('missing resource icon is not shown when there is a project and an inventory.', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
project: { name: 'Foo', id: 2 },
|
||||||
|
inventory: { name: 'Bar', id: 2 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'job_template',
|
||||||
|
ask_inventory_on_launch: true,
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
project: { name: 'Foo', id: 2 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
test('missing resource icon is not shown type is workflow_job_template', () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
url: '/templates/job_template/1',
|
||||||
|
type: 'workflow_job_template',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
test('clicking on template from templates list navigates properly', () => {
|
||||||
|
const history = createMemoryHistory({
|
||||||
|
initialEntries: ['/templates'],
|
||||||
|
});
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
id: 1,
|
||||||
|
name: 'Template 1',
|
||||||
|
summary_fields: {
|
||||||
|
user_capabilities: {
|
||||||
|
edit: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
{ context: { router: { history } } }
|
||||||
|
);
|
||||||
|
wrapper.find('Link').simulate('click', { button: 0 });
|
||||||
|
expect(history.location.pathname).toEqual(
|
||||||
|
'/templates/job_template/1/details'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test('should call api to copy template', async () => {
|
||||||
|
JobTemplatesAPI.copy.mockResolvedValue();
|
||||||
|
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render proper alert modal on copy error', async () => {
|
||||||
|
JobTemplatesAPI.copy.mockRejectedValue(new Error());
|
||||||
|
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
await act(async () =>
|
||||||
|
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||||
|
);
|
||||||
|
wrapper.update();
|
||||||
|
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not render copy button', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
...mockJobTemplateData,
|
||||||
|
summary_fields: { user_capabilities: { copy: false } },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should render visualizer button for workflow', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={{
|
||||||
|
...mockJobTemplateData,
|
||||||
|
type: 'workflow_job_template',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not render visualizer button for job template', async () => {
|
||||||
|
const wrapper = mountWithContexts(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<TemplateListItem
|
||||||
|
isSelected={false}
|
||||||
|
detailUrl="/templates/job_template/1/details"
|
||||||
|
template={mockJobTemplateData}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"type": "job_template",
|
||||||
|
"url": "/api/v2/job_templates/7/",
|
||||||
|
"related": {
|
||||||
|
"named_url": "/api/v2/job_templates/Mike's JT/",
|
||||||
|
"created_by": "/api/v2/users/1/",
|
||||||
|
"modified_by": "/api/v2/users/1/",
|
||||||
|
"labels": "/api/v2/job_templates/7/labels/",
|
||||||
|
"inventory": "/api/v2/inventories/1/",
|
||||||
|
"project": "/api/v2/projects/6/",
|
||||||
|
"credentials": "/api/v2/job_templates/7/credentials/",
|
||||||
|
"last_job": "/api/v2/jobs/12/",
|
||||||
|
"jobs": "/api/v2/job_templates/7/jobs/",
|
||||||
|
"schedules": "/api/v2/job_templates/7/schedules/",
|
||||||
|
"activity_stream": "/api/v2/job_templates/7/activity_stream/",
|
||||||
|
"launch": "/api/v2/job_templates/7/launch/",
|
||||||
|
"notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/",
|
||||||
|
"notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/",
|
||||||
|
"notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/",
|
||||||
|
"access_list": "/api/v2/job_templates/7/access_list/",
|
||||||
|
"survey_spec": "/api/v2/job_templates/7/survey_spec/",
|
||||||
|
"object_roles": "/api/v2/job_templates/7/object_roles/",
|
||||||
|
"instance_groups": "/api/v2/job_templates/7/instance_groups/",
|
||||||
|
"slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/",
|
||||||
|
"copy": "/api/v2/job_templates/7/copy/",
|
||||||
|
"webhook_receiver": "/api/v2/job_templates/7/github/",
|
||||||
|
"webhook_key": "/api/v2/job_templates/7/webhook_key/"
|
||||||
|
},
|
||||||
|
"summary_fields": {
|
||||||
|
"inventory": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Mike's Inventory",
|
||||||
|
"description": "",
|
||||||
|
"has_active_failures": false,
|
||||||
|
"total_hosts": 1,
|
||||||
|
"hosts_with_active_failures": 0,
|
||||||
|
"total_groups": 0,
|
||||||
|
"groups_with_active_failures": 0,
|
||||||
|
"has_inventory_sources": false,
|
||||||
|
"total_inventory_sources": 0,
|
||||||
|
"inventory_sources_with_failures": 0,
|
||||||
|
"organization_id": 1,
|
||||||
|
"kind": ""
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"id": 6,
|
||||||
|
"name": "Mike's Project",
|
||||||
|
"description": "",
|
||||||
|
"status": "successful",
|
||||||
|
"scm_type": "git"
|
||||||
|
},
|
||||||
|
"last_job": {
|
||||||
|
"id": 12,
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"finished": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"status": "successful",
|
||||||
|
"failed": false
|
||||||
|
},
|
||||||
|
"last_update": {
|
||||||
|
"id": 12,
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"status": "successful",
|
||||||
|
"failed": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"modified_by": {
|
||||||
|
"id": 1,
|
||||||
|
"username": "admin",
|
||||||
|
"first_name": "",
|
||||||
|
"last_name": ""
|
||||||
|
},
|
||||||
|
"object_roles": {
|
||||||
|
"admin_role": {
|
||||||
|
"description": "Can manage all aspects of the job template",
|
||||||
|
"name": "Admin",
|
||||||
|
"id": 24
|
||||||
|
},
|
||||||
|
"execute_role": {
|
||||||
|
"description": "May run the job template",
|
||||||
|
"name": "Execute",
|
||||||
|
"id": 25
|
||||||
|
},
|
||||||
|
"read_role": {
|
||||||
|
"description": "May view settings for the job template",
|
||||||
|
"name": "Read",
|
||||||
|
"id": 26
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_capabilities": {
|
||||||
|
"edit": true,
|
||||||
|
"delete": true,
|
||||||
|
"start": true,
|
||||||
|
"schedule": true,
|
||||||
|
"copy": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"count": 1,
|
||||||
|
"results": [{
|
||||||
|
"id": 91,
|
||||||
|
"name": "L_91o2"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"survey": {
|
||||||
|
"title": "",
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"recent_jobs": [{
|
||||||
|
"id": 12,
|
||||||
|
"status": "successful",
|
||||||
|
"finished": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"type": "job"
|
||||||
|
}],
|
||||||
|
"credentials": [{
|
||||||
|
"id": 1,
|
||||||
|
"kind": "ssh",
|
||||||
|
"name": "Credential 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"kind": "awx",
|
||||||
|
"name": "Credential 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webhook_credential": {
|
||||||
|
"id": "1",
|
||||||
|
"name": "Webhook Credential"
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created": "2019-09-30T16:18:34.564820Z",
|
||||||
|
"modified": "2019-10-01T14:47:31.818431Z",
|
||||||
|
"name": "Mike's JT",
|
||||||
|
"description": "",
|
||||||
|
"job_type": "run",
|
||||||
|
"inventory": 1,
|
||||||
|
"project": 6,
|
||||||
|
"playbook": "ping.yml",
|
||||||
|
"scm_branch": "Foo branch",
|
||||||
|
"forks": 0,
|
||||||
|
"limit": "",
|
||||||
|
"verbosity": 0,
|
||||||
|
"extra_vars": "",
|
||||||
|
"job_tags": "T_100,T_200",
|
||||||
|
"force_handlers": false,
|
||||||
|
"skip_tags": "S_100,S_200",
|
||||||
|
"start_at_task": "",
|
||||||
|
"timeout": 0,
|
||||||
|
"use_fact_cache": true,
|
||||||
|
"last_job_run": "2019-10-01T14:34:35.142483Z",
|
||||||
|
"last_job_failed": false,
|
||||||
|
"next_job_run": null,
|
||||||
|
"status": "successful",
|
||||||
|
"host_config_key": "",
|
||||||
|
"ask_scm_branch_on_launch": false,
|
||||||
|
"ask_diff_mode_on_launch": false,
|
||||||
|
"ask_variables_on_launch": false,
|
||||||
|
"ask_limit_on_launch": false,
|
||||||
|
"ask_tags_on_launch": false,
|
||||||
|
"ask_skip_tags_on_launch": false,
|
||||||
|
"ask_job_type_on_launch": false,
|
||||||
|
"ask_verbosity_on_launch": false,
|
||||||
|
"ask_inventory_on_launch": false,
|
||||||
|
"ask_credential_on_launch": false,
|
||||||
|
"survey_enabled": true,
|
||||||
|
"become_enabled": false,
|
||||||
|
"diff_mode": false,
|
||||||
|
"allow_simultaneous": false,
|
||||||
|
"custom_virtualenv": null,
|
||||||
|
"job_slice_count": 1,
|
||||||
|
"webhook_credential": 1,
|
||||||
|
"webhook_key": "asertdyuhjkhgfd234567kjgfds",
|
||||||
|
"webhook_service": "github"
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { default as TemplateList } from './TemplateList';
|
export { default } from './TemplateList';
|
||||||
export { default as TemplateListItem } from './TemplateListItem';
|
export { default as TemplateListItem } from './TemplateListItem';
|
||||||
@@ -78,7 +78,7 @@ function ApplicationListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { CredentialsAPI } from '../../../api';
|
|||||||
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';
|
||||||
import PaginatedDataList, {
|
import {
|
||||||
ToolbarAddButton,
|
ToolbarAddButton,
|
||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
|
import PaginatedTable, {
|
||||||
|
HeaderRow,
|
||||||
|
HeaderCell,
|
||||||
|
} from '../../../components/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';
|
||||||
@@ -114,7 +118,7 @@ function CredentialList({ i18n }) {
|
|||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading}
|
||||||
items={credentials}
|
items={credentials}
|
||||||
@@ -142,7 +146,14 @@ function CredentialList({ i18n }) {
|
|||||||
key: 'modified_by__username__icontains',
|
key: 'modified_by__username__icontains',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
renderItem={item => (
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
||||||
|
<HeaderCell alignRight>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
|
renderRow={(item, index) => (
|
||||||
<CredentialListItem
|
<CredentialListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
credential={item}
|
credential={item}
|
||||||
@@ -150,6 +161,7 @@ function CredentialList({ i18n }) {
|
|||||||
detailUrl={`/credentials/${item.id}/details`}
|
detailUrl={`/credentials/${item.id}/details`}
|
||||||
isSelected={selected.some(row => row.id === item.id)}
|
isSelected={selected.some(row => row.id === item.id)}
|
||||||
onSelect={() => handleSelect(item)}
|
onSelect={() => handleSelect(item)}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
|
|||||||
@@ -57,25 +57,41 @@ describe('<CredentialList />', () => {
|
|||||||
|
|
||||||
test('should check and uncheck the row item', async () => {
|
test('should check and uncheck the row item', async () => {
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-credential-1"]')
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')(true);
|
.invoke('onChange')(true);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-credential-1"]')
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')(false);
|
.invoke('onChange')(false);
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
expect(
|
expect(
|
||||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
wrapper
|
||||||
|
.find('.pf-c-table__check')
|
||||||
|
.first()
|
||||||
|
.find('input')
|
||||||
|
.props().checked
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +121,9 @@ describe('<CredentialList />', () => {
|
|||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-credential-3"]')
|
.find('.pf-c-table__check')
|
||||||
|
.at(2)
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
@@ -122,7 +140,9 @@ describe('<CredentialList />', () => {
|
|||||||
);
|
);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('DataListCheck[id="select-credential-2"]')
|
.find('.pf-c-table__check')
|
||||||
|
.at(1)
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|||||||
@@ -3,31 +3,16 @@ import { string, bool, func } from 'prop-types';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { Button } from '@patternfly/react-core';
|
||||||
Button,
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemRow,
|
|
||||||
DataListItemCells,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
|
||||||
import { timeOfDay } from '../../../util/dates';
|
import { timeOfDay } from '../../../util/dates';
|
||||||
|
|
||||||
import { Credential } from '../../../types';
|
import { Credential } from '../../../types';
|
||||||
import { CredentialsAPI } from '../../../api';
|
import { CredentialsAPI } from '../../../api';
|
||||||
import CopyButton from '../../../components/CopyButton';
|
import CopyButton from '../../../components/CopyButton';
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: repeat(2, 40px);
|
|
||||||
`;
|
|
||||||
|
|
||||||
function CredentialListItem({
|
function CredentialListItem({
|
||||||
credential,
|
credential,
|
||||||
detailUrl,
|
detailUrl,
|
||||||
@@ -35,6 +20,7 @@ function CredentialListItem({
|
|||||||
onSelect,
|
onSelect,
|
||||||
i18n,
|
i18n,
|
||||||
fetchCredentials,
|
fetchCredentials,
|
||||||
|
rowIndex,
|
||||||
}) {
|
}) {
|
||||||
const [isDisabled, setIsDisabled] = useState(false);
|
const [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
|
||||||
@@ -57,64 +43,49 @@ function CredentialListItem({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem
|
<Tr id={`${credential.id}`}>
|
||||||
key={credential.id}
|
<Td
|
||||||
aria-labelledby={labelId}
|
select={{
|
||||||
id={`${credential.id}`}
|
rowIndex,
|
||||||
>
|
isSelected,
|
||||||
<DataListItemRow>
|
onSelect,
|
||||||
<DataListCheck
|
}}
|
||||||
isDisabled={isDisabled}
|
dataLabel={i18n._(t`Selected`)}
|
||||||
id={`select-credential-${credential.id}`}
|
/>
|
||||||
checked={isSelected}
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
onChange={onSelect}
|
<Link to={`${detailUrl}`}>
|
||||||
aria-labelledby={labelId}
|
<b>{credential.name}</b>
|
||||||
/>
|
</Link>
|
||||||
<DataListItemCells
|
</Td>
|
||||||
dataListCells={[
|
<Td dataLabel={i18n._(t`Type`)}>
|
||||||
<DataListCell key="name">
|
{credential.summary_fields.credential_type.name}
|
||||||
<Link to={`${detailUrl}`}>
|
</Td>
|
||||||
<b>{credential.name}</b>
|
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||||
</Link>
|
<ActionItem visible={canEdit} tooltip={i18n._(t`Edit Credential`)}>
|
||||||
</DataListCell>,
|
<Button
|
||||||
<DataListCell key="type">
|
isDisabled={isDisabled}
|
||||||
{credential.summary_fields.credential_type.name}
|
aria-label={i18n._(t`Edit Credential`)}
|
||||||
</DataListCell>,
|
variant="plain"
|
||||||
]}
|
component={Link}
|
||||||
/>
|
to={`/credentials/${credential.id}/edit`}
|
||||||
<DataListAction
|
>
|
||||||
aria-label="actions"
|
<PencilAltIcon />
|
||||||
aria-labelledby={labelId}
|
</Button>
|
||||||
id={labelId}
|
</ActionItem>
|
||||||
>
|
<ActionItem visible={credential.summary_fields.user_capabilities.copy}>
|
||||||
{canEdit && (
|
<CopyButton
|
||||||
<Tooltip content={i18n._(t`Edit Credential`)} position="top">
|
isDisabled={isDisabled}
|
||||||
<Button
|
onCopyStart={handleCopyStart}
|
||||||
isDisabled={isDisabled}
|
onCopyFinish={handleCopyFinish}
|
||||||
aria-label={i18n._(t`Edit Credential`)}
|
copyItem={copyCredential}
|
||||||
variant="plain"
|
helperText={{
|
||||||
component={Link}
|
tooltip: i18n._(t`Copy Credential`),
|
||||||
to={`/credentials/${credential.id}/edit`}
|
errorMessage: i18n._(t`Failed to copy credential.`),
|
||||||
>
|
}}
|
||||||
<PencilAltIcon />
|
/>
|
||||||
</Button>
|
</ActionItem>
|
||||||
</Tooltip>
|
</ActionsTd>
|
||||||
)}
|
</Tr>
|
||||||
{credential.summary_fields.user_capabilities.copy && (
|
|
||||||
<CopyButton
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onCopyStart={handleCopyStart}
|
|
||||||
onCopyFinish={handleCopyFinish}
|
|
||||||
copyItem={copyCredential}
|
|
||||||
helperText={{
|
|
||||||
tooltip: i18n._(t`Copy Credential`),
|
|
||||||
errorMessage: i18n._(t`Failed to copy credential.`),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
|
||||||
</DataListItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,24 +16,32 @@ describe('<CredentialListItem />', () => {
|
|||||||
|
|
||||||
test('edit button shown to users with edit capabilities', () => {
|
test('edit button shown to users with edit capabilities', () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<CredentialListItem
|
<table>
|
||||||
credential={mockCredentials.results[0]}
|
<tbody>
|
||||||
detailUrl="/foo/bar"
|
<CredentialListItem
|
||||||
isSelected={false}
|
credential={mockCredentials.results[0]}
|
||||||
onSelect={() => {}}
|
detailUrl="/foo/bar"
|
||||||
/>
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('edit button hidden from users without edit capabilities', () => {
|
test('edit button hidden from users without edit capabilities', () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<CredentialListItem
|
<table>
|
||||||
credential={mockCredentials.results[1]}
|
<tbody>
|
||||||
detailUrl="/foo/bar"
|
<CredentialListItem
|
||||||
isSelected={false}
|
credential={mockCredentials.results[1]}
|
||||||
onSelect={() => {}}
|
detailUrl="/foo/bar"
|
||||||
/>
|
isSelected={false}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||||
});
|
});
|
||||||
@@ -41,12 +49,16 @@ describe('<CredentialListItem />', () => {
|
|||||||
CredentialsAPI.copy.mockResolvedValue();
|
CredentialsAPI.copy.mockResolvedValue();
|
||||||
|
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<CredentialListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
detailUrl="/foo/bar"
|
<CredentialListItem
|
||||||
credential={mockCredentials.results[0]}
|
isSelected={false}
|
||||||
onSelect={() => {}}
|
detailUrl="/foo/bar"
|
||||||
/>
|
credential={mockCredentials.results[0]}
|
||||||
|
onSelect={() => {}}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
@@ -60,12 +72,16 @@ describe('<CredentialListItem />', () => {
|
|||||||
CredentialsAPI.copy.mockRejectedValue(new Error());
|
CredentialsAPI.copy.mockRejectedValue(new Error());
|
||||||
|
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<CredentialListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
detailUrl="/foo/bar"
|
<CredentialListItem
|
||||||
onSelect={() => {}}
|
isSelected={false}
|
||||||
credential={mockCredentials.results[0]}
|
detailUrl="/foo/bar"
|
||||||
/>
|
onSelect={() => {}}
|
||||||
|
credential={mockCredentials.results[0]}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
await act(async () =>
|
await act(async () =>
|
||||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||||
@@ -77,12 +93,16 @@ describe('<CredentialListItem />', () => {
|
|||||||
|
|
||||||
test('should not render copy button', async () => {
|
test('should not render copy button', async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<CredentialListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
detailUrl="/foo/bar"
|
<CredentialListItem
|
||||||
onSelect={() => {}}
|
isSelected={false}
|
||||||
credential={mockCredentials.results[1]}
|
detailUrl="/foo/bar"
|
||||||
/>
|
onSelect={() => {}}
|
||||||
|
credential={mockCredentials.results[1]}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ function CredentialForm({
|
|||||||
<Button
|
<Button
|
||||||
id="credential-form-cancel-button"
|
id="credential-form-cancel-button"
|
||||||
aria-label={i18n._(t`Cancel`)}
|
aria-label={i18n._(t`Cancel`)}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function CredentialTypeListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import JobList from '../../components/JobList';
|
|||||||
import ContentLoading from '../../components/ContentLoading';
|
import ContentLoading from '../../components/ContentLoading';
|
||||||
import LineChart from './shared/LineChart';
|
import LineChart from './shared/LineChart';
|
||||||
import Count from './shared/Count';
|
import Count from './shared/Count';
|
||||||
import DashboardTemplateList from './shared/DashboardTemplateList';
|
import TemplateList from '../../components/TemplateList';
|
||||||
|
|
||||||
const Counts = styled.div`
|
const Counts = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -247,7 +247,9 @@ function Dashboard({ i18n }) {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
)}
|
)}
|
||||||
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
|
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
|
||||||
{activeTabId === 2 && <DashboardTemplateList />}
|
{activeTabId === 2 && (
|
||||||
|
<TemplateList defaultParams={{ page_size: 5 }} />
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</MainPageSection>
|
</MainPageSection>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe('<Dashboard />', () => {
|
|||||||
.simulate('click');
|
.simulate('click');
|
||||||
});
|
});
|
||||||
pageWrapper.update();
|
pageWrapper.update();
|
||||||
expect(pageWrapper.find('DashboardTemplateList').length).toBe(1);
|
expect(pageWrapper.find('TemplateList').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders month-based/all job type chart by default', () => {
|
test('renders month-based/all job type chart by default', () => {
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
import React, { Fragment, useEffect, useState, useCallback } from 'react';
|
|
||||||
import { useLocation, Link } from 'react-router-dom';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { Card, DropdownItem } from '@patternfly/react-core';
|
|
||||||
|
|
||||||
import {
|
|
||||||
JobTemplatesAPI,
|
|
||||||
UnifiedJobTemplatesAPI,
|
|
||||||
WorkflowJobTemplatesAPI,
|
|
||||||
} from '../../../api';
|
|
||||||
import AlertModal from '../../../components/AlertModal';
|
|
||||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
|
||||||
import PaginatedDataList, {
|
|
||||||
ToolbarDeleteButton,
|
|
||||||
} from '../../../components/PaginatedDataList';
|
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
|
||||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
|
||||||
import useWsTemplates from '../../../util/useWsTemplates';
|
|
||||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
|
||||||
|
|
||||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
|
||||||
|
|
||||||
const QS_CONFIG = getQSConfig(
|
|
||||||
'template',
|
|
||||||
{
|
|
||||||
page: 1,
|
|
||||||
page_size: 5,
|
|
||||||
order_by: 'name',
|
|
||||||
type: 'job_template,workflow_job_template',
|
|
||||||
},
|
|
||||||
['id', 'page', 'page_size']
|
|
||||||
);
|
|
||||||
|
|
||||||
function DashboardTemplateList({ i18n }) {
|
|
||||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
|
||||||
// workflow_job_template so the params sent to the API match what the api expects.
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState([]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
result: {
|
|
||||||
results,
|
|
||||||
count,
|
|
||||||
jtActions,
|
|
||||||
wfjtActions,
|
|
||||||
relatedSearchableKeys,
|
|
||||||
searchableKeys,
|
|
||||||
},
|
|
||||||
error: contentError,
|
|
||||||
isLoading,
|
|
||||||
request: fetchTemplates,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
const params = parseQueryString(QS_CONFIG, location.search);
|
|
||||||
const responses = await Promise.all([
|
|
||||||
UnifiedJobTemplatesAPI.read(params),
|
|
||||||
JobTemplatesAPI.readOptions(),
|
|
||||||
WorkflowJobTemplatesAPI.readOptions(),
|
|
||||||
UnifiedJobTemplatesAPI.readOptions(),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
results: responses[0].data.results,
|
|
||||||
count: responses[0].data.count,
|
|
||||||
jtActions: responses[1].data.actions,
|
|
||||||
wfjtActions: responses[2].data.actions,
|
|
||||||
relatedSearchableKeys: (
|
|
||||||
responses[3]?.data?.related_search_fields || []
|
|
||||||
).map(val => val.slice(0, -8)),
|
|
||||||
searchableKeys: Object.keys(
|
|
||||||
responses[3].data.actions?.GET || {}
|
|
||||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
|
||||||
};
|
|
||||||
}, [location]),
|
|
||||||
{
|
|
||||||
results: [],
|
|
||||||
count: 0,
|
|
||||||
jtActions: {},
|
|
||||||
wfjtActions: {},
|
|
||||||
relatedSearchableKeys: [],
|
|
||||||
searchableKeys: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTemplates();
|
|
||||||
}, [fetchTemplates]);
|
|
||||||
|
|
||||||
const templates = useWsTemplates(results);
|
|
||||||
|
|
||||||
const isAllSelected =
|
|
||||||
selected.length === templates.length && selected.length > 0;
|
|
||||||
const {
|
|
||||||
isLoading: isDeleteLoading,
|
|
||||||
deleteItems: deleteTemplates,
|
|
||||||
deletionError,
|
|
||||||
clearDeletionError,
|
|
||||||
} = useDeleteItems(
|
|
||||||
useCallback(() => {
|
|
||||||
return Promise.all(
|
|
||||||
selected.map(({ type, id }) => {
|
|
||||||
if (type === 'job_template') {
|
|
||||||
return JobTemplatesAPI.destroy(id);
|
|
||||||
}
|
|
||||||
if (type === 'workflow_job_template') {
|
|
||||||
return WorkflowJobTemplatesAPI.destroy(id);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [selected]),
|
|
||||||
{
|
|
||||||
qsConfig: QS_CONFIG,
|
|
||||||
allItemsSelected: isAllSelected,
|
|
||||||
fetchItems: fetchTemplates,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTemplateDelete = async () => {
|
|
||||||
await deleteTemplates();
|
|
||||||
setSelected([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = isSelected => {
|
|
||||||
setSelected(isSelected ? [...templates] : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = template => {
|
|
||||||
if (selected.some(s => s.id === template.id)) {
|
|
||||||
setSelected(selected.filter(s => s.id !== template.id));
|
|
||||||
} else {
|
|
||||||
setSelected(selected.concat(template));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const canAddJT =
|
|
||||||
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
|
|
||||||
const canAddWFJT =
|
|
||||||
wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST');
|
|
||||||
|
|
||||||
const addTemplate = i18n._(t`Add job template`);
|
|
||||||
const addWFTemplate = i18n._(t`Add workflow template`);
|
|
||||||
const addButton = (
|
|
||||||
<AddDropDownButton
|
|
||||||
key="add"
|
|
||||||
dropdownItems={[
|
|
||||||
<DropdownItem
|
|
||||||
key={addTemplate}
|
|
||||||
component={Link}
|
|
||||||
to="/templates/job_template/add/"
|
|
||||||
aria-label={addTemplate}
|
|
||||||
>
|
|
||||||
{addTemplate}
|
|
||||||
</DropdownItem>,
|
|
||||||
<DropdownItem
|
|
||||||
component={Link}
|
|
||||||
to="/templates/workflow_job_template/add/"
|
|
||||||
key={addWFTemplate}
|
|
||||||
aria-label={addWFTemplate}
|
|
||||||
>
|
|
||||||
{addWFTemplate}
|
|
||||||
</DropdownItem>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<Card>
|
|
||||||
<PaginatedDataList
|
|
||||||
contentError={contentError}
|
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
|
||||||
items={templates}
|
|
||||||
itemCount={count}
|
|
||||||
pluralizedItemName={i18n._(t`Templates`)}
|
|
||||||
qsConfig={QS_CONFIG}
|
|
||||||
onRowClick={handleSelect}
|
|
||||||
toolbarSearchColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name__icontains',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Description`),
|
|
||||||
key: 'description__icontains',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Type`),
|
|
||||||
key: 'or__type',
|
|
||||||
options: [
|
|
||||||
[`job_template`, i18n._(t`Job Template`)],
|
|
||||||
[`workflow_job_template`, i18n._(t`Workflow Template`)],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Playbook name`),
|
|
||||||
key: 'job_template__playbook__icontains',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Created By (Username)`),
|
|
||||||
key: 'created_by__username__icontains',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Modified By (Username)`),
|
|
||||||
key: 'modified_by__username__icontains',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Inventory`),
|
|
||||||
key: 'job_template__inventory__id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Last Job Run`),
|
|
||||||
key: 'last_job_run',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Modified`),
|
|
||||||
key: 'modified',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Project`),
|
|
||||||
key: 'jobtemplate__project__id',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: i18n._(t`Type`),
|
|
||||||
key: 'type',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
|
||||||
renderToolbar={props => (
|
|
||||||
<DatalistToolbar
|
|
||||||
{...props}
|
|
||||||
showSelectAll
|
|
||||||
isAllSelected={isAllSelected}
|
|
||||||
onSelectAll={handleSelectAll}
|
|
||||||
qsConfig={QS_CONFIG}
|
|
||||||
additionalControls={[
|
|
||||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
|
||||||
<ToolbarDeleteButton
|
|
||||||
key="delete"
|
|
||||||
onDelete={handleTemplateDelete}
|
|
||||||
itemsToDelete={selected}
|
|
||||||
pluralizedItemName="Templates"
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderItem={template => (
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
key={template.id}
|
|
||||||
value={template.name}
|
|
||||||
template={template}
|
|
||||||
detailUrl={`/templates/${template.type}/${template.id}`}
|
|
||||||
onSelect={() => handleSelect(template)}
|
|
||||||
isSelected={selected.some(row => row.id === template.id)}
|
|
||||||
fetchTemplates={fetchTemplates}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
<AlertModal
|
|
||||||
aria-label={i18n._(t`Deletion Error`)}
|
|
||||||
isOpen={deletionError}
|
|
||||||
variant="error"
|
|
||||||
title={i18n._(t`Error!`)}
|
|
||||||
onClose={clearDeletionError}
|
|
||||||
>
|
|
||||||
{i18n._(t`Failed to delete one or more templates.`)}
|
|
||||||
<ErrorDetail error={deletionError} />
|
|
||||||
</AlertModal>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withI18n()(DashboardTemplateList);
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import {
|
|
||||||
JobTemplatesAPI,
|
|
||||||
UnifiedJobTemplatesAPI,
|
|
||||||
WorkflowJobTemplatesAPI,
|
|
||||||
} from '../../../api';
|
|
||||||
import {
|
|
||||||
mountWithContexts,
|
|
||||||
waitForElement,
|
|
||||||
} from '../../../../testUtils/enzymeHelpers';
|
|
||||||
|
|
||||||
import DashboardTemplateList from './DashboardTemplateList';
|
|
||||||
|
|
||||||
jest.mock('../../../api');
|
|
||||||
|
|
||||||
const mockTemplates = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Job Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
edit: true,
|
|
||||||
copy: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Job Template 2',
|
|
||||||
url: '/templates/job_template/2',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Job Template 3',
|
|
||||||
url: '/templates/job_template/3',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Workflow Job Template 1',
|
|
||||||
url: '/templates/workflow_job_template/4',
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Workflow Job Template 2',
|
|
||||||
url: '/templates/workflow_job_template/5',
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('<DashboardTemplateList />', () => {
|
|
||||||
let debug;
|
|
||||||
beforeEach(() => {
|
|
||||||
UnifiedJobTemplatesAPI.read.mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
count: mockTemplates.length,
|
|
||||||
results: mockTemplates,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
actions: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
|
||||||
global.console.debug = () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
global.console.debug = debug;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initially renders successfully', async () => {
|
|
||||||
await act(async () => {
|
|
||||||
mountWithContexts(
|
|
||||||
<DashboardTemplateList
|
|
||||||
match={{ path: '/templates', url: '/templates' }}
|
|
||||||
location={{ search: '', pathname: '/templates' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Templates are retrieved from the api and the components finishes loading', async () => {
|
|
||||||
let wrapper;
|
|
||||||
await act(async () => {
|
|
||||||
wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
});
|
|
||||||
expect(UnifiedJobTemplatesAPI.read).toBeCalled();
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleSelect is called when a template list item is selected', async () => {
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
const checkBox = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(1)
|
|
||||||
.find('input');
|
|
||||||
|
|
||||||
checkBox.simulate('change', {
|
|
||||||
target: {
|
|
||||||
id: 2,
|
|
||||||
name: 'Job Template 2',
|
|
||||||
url: '/templates/job_template/2',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: { user_capabilities: { delete: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(1)
|
|
||||||
.prop('isSelected')
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handleSelectAll is called when a template list item is selected', async () => {
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false);
|
|
||||||
|
|
||||||
const toolBarCheckBox = wrapper.find('Checkbox#select-all');
|
|
||||||
act(() => {
|
|
||||||
toolBarCheckBox.prop('onChange')(true);
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete button is disabled if user does not have delete capabilities on a selected template', async () => {
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
const deleteableItem = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(0)
|
|
||||||
.find('input');
|
|
||||||
const nonDeleteableItem = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(4)
|
|
||||||
.find('input');
|
|
||||||
|
|
||||||
deleteableItem.simulate('change', {
|
|
||||||
id: 1,
|
|
||||||
name: 'Job Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
|
||||||
false
|
|
||||||
);
|
|
||||||
deleteableItem.simulate('change', {
|
|
||||||
id: 1,
|
|
||||||
name: 'Job Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
nonDeleteableItem.simulate('change', {
|
|
||||||
id: 5,
|
|
||||||
name: 'Workflow Job Template 2',
|
|
||||||
url: '/templates/workflow_job_template/5',
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('api is called to delete templates for each selected template.', async () => {
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
const jobTemplate = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(1)
|
|
||||||
.find('input');
|
|
||||||
const workflowJobTemplate = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(3)
|
|
||||||
.find('input');
|
|
||||||
|
|
||||||
jobTemplate.simulate('change', {
|
|
||||||
target: {
|
|
||||||
id: 2,
|
|
||||||
name: 'Job Template 2',
|
|
||||||
url: '/templates/job_template/2',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: { user_capabilities: { delete: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
workflowJobTemplate.simulate('change', {
|
|
||||||
target: {
|
|
||||||
id: 4,
|
|
||||||
name: 'Workflow Job Template 1',
|
|
||||||
url: '/templates/workflow_job_template/4',
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
delete: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
await act(async () => {
|
|
||||||
await wrapper
|
|
||||||
.find('button[aria-label="confirm delete"]')
|
|
||||||
.prop('onClick')();
|
|
||||||
});
|
|
||||||
expect(JobTemplatesAPI.destroy).toBeCalledWith(2);
|
|
||||||
expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('error is shown when template not successfully deleted from api', async () => {
|
|
||||||
JobTemplatesAPI.destroy.mockRejectedValue(
|
|
||||||
new Error({
|
|
||||||
response: {
|
|
||||||
config: {
|
|
||||||
method: 'delete',
|
|
||||||
url: '/api/v2/job_templates/1',
|
|
||||||
},
|
|
||||||
data: 'An error occurred',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
const checkBox = wrapper
|
|
||||||
.find('DashboardTemplateListItem')
|
|
||||||
.at(1)
|
|
||||||
.find('input');
|
|
||||||
|
|
||||||
checkBox.simulate('change', {
|
|
||||||
target: {
|
|
||||||
id: 'a',
|
|
||||||
name: 'Job Template 2',
|
|
||||||
url: '/templates/job_template/2',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: { user_capabilities: { delete: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
|
||||||
});
|
|
||||||
wrapper.update();
|
|
||||||
await act(async () => {
|
|
||||||
await wrapper
|
|
||||||
.find('button[aria-label="confirm delete"]')
|
|
||||||
.prop('onClick')();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitForElement(
|
|
||||||
wrapper,
|
|
||||||
'Modal[aria-label="Deletion Error"]',
|
|
||||||
el => el.props().isOpen === true && el.props().title === 'Error!'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test('should properly copy template', async () => {
|
|
||||||
JobTemplatesAPI.copy.mockResolvedValue({});
|
|
||||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
|
||||||
await act(async () => {
|
|
||||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
|
||||||
});
|
|
||||||
await act(async () =>
|
|
||||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
|
||||||
);
|
|
||||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
|
||||||
expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled();
|
|
||||||
wrapper.update();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import 'styled-components/macro';
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemRow,
|
|
||||||
DataListItemCells,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import {
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
PencilAltIcon,
|
|
||||||
ProjectDiagramIcon,
|
|
||||||
RocketIcon,
|
|
||||||
} from '@patternfly/react-icons';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
import DataListCell from '../../../components/DataListCell';
|
|
||||||
import { timeOfDay } from '../../../util/dates';
|
|
||||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
|
|
||||||
import LaunchButton from '../../../components/LaunchButton';
|
|
||||||
import Sparkline from '../../../components/Sparkline';
|
|
||||||
import { toTitleCase } from '../../../util/strings';
|
|
||||||
import CopyButton from '../../../components/CopyButton';
|
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
|
||||||
align-items: center;
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 16px;
|
|
||||||
grid-template-columns: repeat(4, 40px);
|
|
||||||
`;
|
|
||||||
|
|
||||||
function DashboardTemplateListItem({
|
|
||||||
i18n,
|
|
||||||
template,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
detailUrl,
|
|
||||||
fetchTemplates,
|
|
||||||
}) {
|
|
||||||
const [isDisabled, setIsDisabled] = useState(false);
|
|
||||||
const labelId = `check-action-${template.id}`;
|
|
||||||
|
|
||||||
const copyTemplate = useCallback(async () => {
|
|
||||||
if (template.type === 'job_template') {
|
|
||||||
await JobTemplatesAPI.copy(template.id, {
|
|
||||||
name: `${template.name} @ ${timeOfDay()}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await WorkflowJobTemplatesAPI.copy(template.id, {
|
|
||||||
name: `${template.name} @ ${timeOfDay()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await fetchTemplates();
|
|
||||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
|
||||||
|
|
||||||
const handleCopyStart = useCallback(() => {
|
|
||||||
setIsDisabled(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCopyFinish = useCallback(() => {
|
|
||||||
setIsDisabled(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const missingResourceIcon =
|
|
||||||
template.type === 'job_template' &&
|
|
||||||
(!template.summary_fields.project ||
|
|
||||||
(!template.summary_fields.inventory &&
|
|
||||||
!template.ask_inventory_on_launch));
|
|
||||||
return (
|
|
||||||
<DataListItem aria-labelledby={labelId} id={`${template.id}`}>
|
|
||||||
<DataListItemRow>
|
|
||||||
<DataListCheck
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
id={`select-jobTemplate-${template.id}`}
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={onSelect}
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
/>
|
|
||||||
<DataListItemCells
|
|
||||||
dataListCells={[
|
|
||||||
<DataListCell key="name" id={labelId}>
|
|
||||||
<span>
|
|
||||||
<Link to={`${detailUrl}`}>
|
|
||||||
<b>{template.name}</b>
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
{missingResourceIcon && (
|
|
||||||
<span>
|
|
||||||
<Tooltip
|
|
||||||
content={i18n._(
|
|
||||||
t`Resources are missing from this template.`
|
|
||||||
)}
|
|
||||||
position="right"
|
|
||||||
>
|
|
||||||
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="type">
|
|
||||||
{toTitleCase(template.type)}
|
|
||||||
</DataListCell>,
|
|
||||||
<DataListCell key="sparkline">
|
|
||||||
<Sparkline jobs={template.summary_fields.recent_jobs} />
|
|
||||||
</DataListCell>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
|
||||||
{template.type === 'workflow_job_template' && (
|
|
||||||
<Tooltip content={i18n._(t`Visualizer`)} position="top">
|
|
||||||
<Button
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={i18n._(t`Visualizer`)}
|
|
||||||
css="grid-column: 1"
|
|
||||||
variant="plain"
|
|
||||||
component={Link}
|
|
||||||
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
|
||||||
>
|
|
||||||
<ProjectDiagramIcon />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{template.summary_fields.user_capabilities.start && (
|
|
||||||
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
|
||||||
<LaunchButton resource={template}>
|
|
||||||
{({ handleLaunch }) => (
|
|
||||||
<Button
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={i18n._(t`Launch template`)}
|
|
||||||
css="grid-column: 2"
|
|
||||||
variant="plain"
|
|
||||||
onClick={handleLaunch}
|
|
||||||
>
|
|
||||||
<RocketIcon />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</LaunchButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{template.summary_fields.user_capabilities.edit && (
|
|
||||||
<Tooltip content={i18n._(t`Edit Template`)} position="top">
|
|
||||||
<Button
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={i18n._(t`Edit Template`)}
|
|
||||||
css="grid-column: 3"
|
|
||||||
variant="plain"
|
|
||||||
component={Link}
|
|
||||||
to={`/templates/${template.type}/${template.id}/edit`}
|
|
||||||
>
|
|
||||||
<PencilAltIcon />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{template.summary_fields.user_capabilities.copy && (
|
|
||||||
<CopyButton
|
|
||||||
helperText={{
|
|
||||||
tooltip: i18n._(t`Copy Template`),
|
|
||||||
errorMessage: i18n._(t`Failed to copy template.`),
|
|
||||||
}}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
onCopyStart={handleCopyStart}
|
|
||||||
onCopyFinish={handleCopyFinish}
|
|
||||||
copyItem={copyTemplate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
|
||||||
</DataListItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { DashboardTemplateListItem as _TemplateListItem };
|
|
||||||
export default withI18n()(DashboardTemplateListItem);
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
|
|
||||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
|
||||||
import { JobTemplatesAPI } from '../../../api';
|
|
||||||
|
|
||||||
import mockJobTemplateData from './data.job_template.json';
|
|
||||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
|
||||||
|
|
||||||
jest.mock('../../../api');
|
|
||||||
|
|
||||||
describe('<DashboardTemplateListItem />', () => {
|
|
||||||
test('launch button shown to users with start capabilities', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
start: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
|
|
||||||
});
|
|
||||||
test('launch button hidden from users without start capabilities', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
start: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
|
|
||||||
});
|
|
||||||
test('edit button shown to users with edit capabilities', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
|
||||||
});
|
|
||||||
test('edit button hidden from users without edit capabilities', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
|
||||||
});
|
|
||||||
test('missing resource icon is shown.', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
|
|
||||||
});
|
|
||||||
test('missing resource icon is not shown when there is a project and an inventory.', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
project: { name: 'Foo', id: 2 },
|
|
||||||
inventory: { name: 'Bar', id: 2 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
|
||||||
});
|
|
||||||
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'job_template',
|
|
||||||
ask_inventory_on_launch: true,
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
project: { name: 'Foo', id: 2 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
|
||||||
});
|
|
||||||
test('missing resource icon is not shown type is workflow_job_template', () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
url: '/templates/job_template/1',
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
|
||||||
});
|
|
||||||
test('clicking on template from templates list navigates properly', () => {
|
|
||||||
const history = createMemoryHistory({
|
|
||||||
initialEntries: ['/templates'],
|
|
||||||
});
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={{
|
|
||||||
id: 1,
|
|
||||||
name: 'Template 1',
|
|
||||||
summary_fields: {
|
|
||||||
user_capabilities: {
|
|
||||||
edit: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
{ context: { router: { history } } }
|
|
||||||
);
|
|
||||||
wrapper.find('Link').simulate('click', { button: 0 });
|
|
||||||
expect(history.location.pathname).toEqual(
|
|
||||||
'/templates/job_template/1/details'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test('should call api to copy template', async () => {
|
|
||||||
JobTemplatesAPI.copy.mockResolvedValue();
|
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={mockJobTemplateData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await act(async () =>
|
|
||||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
|
||||||
);
|
|
||||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render proper alert modal on copy error', async () => {
|
|
||||||
JobTemplatesAPI.copy.mockRejectedValue(new Error());
|
|
||||||
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={mockJobTemplateData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
await act(async () =>
|
|
||||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
|
||||||
);
|
|
||||||
wrapper.update();
|
|
||||||
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not render copy button', async () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={{
|
|
||||||
...mockJobTemplateData,
|
|
||||||
summary_fields: { user_capabilities: { copy: false } },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should render visualizer button for workflow', async () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={{
|
|
||||||
...mockJobTemplateData,
|
|
||||||
type: 'workflow_job_template',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not render visualizer button for job template', async () => {
|
|
||||||
const wrapper = mountWithContexts(
|
|
||||||
<DashboardTemplateListItem
|
|
||||||
isSelected={false}
|
|
||||||
detailUrl="/templates/job_template/1/details"
|
|
||||||
template={mockJobTemplateData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -42,7 +42,7 @@ function HostGroupItem({ i18n, group, inventoryId, isSelected, onSelect }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import { HostsAPI } from '../../../api';
|
|||||||
import AlertModal from '../../../components/AlertModal';
|
import AlertModal from '../../../components/AlertModal';
|
||||||
import DataListToolbar from '../../../components/DataListToolbar';
|
import DataListToolbar from '../../../components/DataListToolbar';
|
||||||
import ErrorDetail from '../../../components/ErrorDetail';
|
import ErrorDetail from '../../../components/ErrorDetail';
|
||||||
import PaginatedDataList, {
|
import {
|
||||||
ToolbarAddButton,
|
ToolbarAddButton,
|
||||||
ToolbarDeleteButton,
|
ToolbarDeleteButton,
|
||||||
} from '../../../components/PaginatedDataList';
|
} from '../../../components/PaginatedDataList';
|
||||||
|
import PaginatedTable, {
|
||||||
|
HeaderRow,
|
||||||
|
HeaderCell,
|
||||||
|
} from '../../../components/PaginatedTable';
|
||||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||||
import {
|
import {
|
||||||
encodeQueryString,
|
encodeQueryString,
|
||||||
@@ -130,7 +134,7 @@ function HostList({ i18n }) {
|
|||||||
return (
|
return (
|
||||||
<PageSection>
|
<PageSection>
|
||||||
<Card>
|
<Card>
|
||||||
<PaginatedDataList
|
<PaginatedTable
|
||||||
contentError={contentError}
|
contentError={contentError}
|
||||||
hasContentLoading={isLoading || isDeleteLoading}
|
hasContentLoading={isLoading || isDeleteLoading}
|
||||||
items={hosts}
|
items={hosts}
|
||||||
@@ -157,14 +161,15 @@ function HostList({ i18n }) {
|
|||||||
key: 'modified_by__username__icontains',
|
key: 'modified_by__username__icontains',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
toolbarSortColumns={[
|
|
||||||
{
|
|
||||||
name: i18n._(t`Name`),
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
toolbarSearchableKeys={searchableKeys}
|
toolbarSearchableKeys={searchableKeys}
|
||||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||||
|
headerRow={
|
||||||
|
<HeaderRow qsConfig={QS_CONFIG}>
|
||||||
|
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Inventory`)}</HeaderCell>
|
||||||
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
}
|
||||||
renderToolbar={props => (
|
renderToolbar={props => (
|
||||||
<DataListToolbar
|
<DataListToolbar
|
||||||
{...props}
|
{...props}
|
||||||
@@ -193,13 +198,14 @@ function HostList({ i18n }) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderItem={host => (
|
renderRow={(host, index) => (
|
||||||
<HostListItem
|
<HostListItem
|
||||||
key={host.id}
|
key={host.id}
|
||||||
host={host}
|
host={host}
|
||||||
detailUrl={`${match.url}/${host.id}/details`}
|
detailUrl={`${match.url}/${host.id}/details`}
|
||||||
isSelected={selected.some(row => row.id === host.id)}
|
isSelected={selected.some(row => row.id === host.id)}
|
||||||
onSelect={() => handleSelect(host)}
|
onSelect={() => handleSelect(host)}
|
||||||
|
rowIndex={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
emptyStateControls={
|
emptyStateControls={
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ describe('<HostList />', () => {
|
|||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('input#select-host-1')
|
.find('.pf-c-table__check')
|
||||||
.closest('DataListCheck')
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
@@ -147,8 +148,9 @@ describe('<HostList />', () => {
|
|||||||
).toEqual(true);
|
).toEqual(true);
|
||||||
act(() => {
|
act(() => {
|
||||||
wrapper
|
wrapper
|
||||||
.find('input#select-host-1')
|
.find('.pf-c-table__check')
|
||||||
.closest('DataListCheck')
|
.first()
|
||||||
|
.find('input')
|
||||||
.invoke('onChange')();
|
.invoke('onChange')();
|
||||||
});
|
});
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
|
|||||||
@@ -1,91 +1,67 @@
|
|||||||
import 'styled-components/macro';
|
import 'styled-components/macro';
|
||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
import { string, bool, func } from 'prop-types';
|
import { string, bool, func } 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 { Button } from '@patternfly/react-core';
|
||||||
Button,
|
import { Tr, Td } from '@patternfly/react-table';
|
||||||
DataListAction as _DataListAction,
|
|
||||||
DataListCheck,
|
|
||||||
DataListItem,
|
|
||||||
DataListItemRow,
|
|
||||||
DataListItemCells,
|
|
||||||
Tooltip,
|
|
||||||
} from '@patternfly/react-core';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||||
import styled from 'styled-components';
|
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||||
import DataListCell from '../../../components/DataListCell';
|
|
||||||
|
|
||||||
import Sparkline from '../../../components/Sparkline';
|
|
||||||
import { Host } from '../../../types';
|
import { Host } from '../../../types';
|
||||||
import HostToggle from '../../../components/HostToggle';
|
import HostToggle from '../../../components/HostToggle';
|
||||||
|
|
||||||
const DataListAction = styled(_DataListAction)`
|
function HostListItem({
|
||||||
align-items: center;
|
i18n,
|
||||||
display: grid;
|
host,
|
||||||
grid-gap: 24px;
|
isSelected,
|
||||||
grid-template-columns: 92px 40px;
|
onSelect,
|
||||||
`;
|
detailUrl,
|
||||||
|
rowIndex,
|
||||||
function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
|
}) {
|
||||||
const labelId = `check-action-${host.id}`;
|
const labelId = `check-action-${host.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}>
|
<Tr id={`host-row-${host.id}`}>
|
||||||
<DataListItemRow>
|
<Td
|
||||||
<DataListCheck
|
select={{
|
||||||
id={`select-host-${host.id}`}
|
rowIndex,
|
||||||
checked={isSelected}
|
isSelected,
|
||||||
onChange={onSelect}
|
onSelect,
|
||||||
aria-labelledby={labelId}
|
}}
|
||||||
/>
|
dataLabel={i18n._(t`Selected`)}
|
||||||
<DataListItemCells
|
/>
|
||||||
dataListCells={[
|
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||||
<DataListCell key="name">
|
<Link to={`${detailUrl}`}>
|
||||||
<Link to={`${detailUrl}`}>
|
<b>{host.name}</b>
|
||||||
<b>{host.name}</b>
|
</Link>
|
||||||
</Link>
|
</Td>
|
||||||
</DataListCell>,
|
<Td dataLabel={i18n._(t`Inventory`)}>
|
||||||
<DataListCell key="recentJobs">
|
{host.summary_fields.inventory && (
|
||||||
<Sparkline jobs={host.summary_fields.recent_jobs} />
|
<Link
|
||||||
</DataListCell>,
|
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
|
||||||
<DataListCell key="inventory">
|
>
|
||||||
{host.summary_fields.inventory && (
|
{host.summary_fields.inventory.name}
|
||||||
<Fragment>
|
</Link>
|
||||||
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
|
)}
|
||||||
<Link
|
</Td>
|
||||||
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
|
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
|
||||||
>
|
<HostToggle host={host} />
|
||||||
{host.summary_fields.inventory.name}
|
<ActionItem
|
||||||
</Link>
|
visible={host.summary_fields.user_capabilities.edit}
|
||||||
</Fragment>
|
tooltip={i18n._(t`Edit Host`)}
|
||||||
)}
|
|
||||||
</DataListCell>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<DataListAction
|
|
||||||
aria-label="actions"
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
id={labelId}
|
|
||||||
>
|
>
|
||||||
<HostToggle host={host} />
|
<Button
|
||||||
{host.summary_fields.user_capabilities.edit ? (
|
aria-label={i18n._(t`Edit Host`)}
|
||||||
<Tooltip content={i18n._(t`Edit Host`)} position="top">
|
variant="plain"
|
||||||
<Button
|
component={Link}
|
||||||
aria-label={i18n._(t`Edit Host`)}
|
to={`/hosts/${host.id}/edit`}
|
||||||
variant="plain"
|
>
|
||||||
component={Link}
|
<PencilAltIcon />
|
||||||
to={`/hosts/${host.id}/edit`}
|
</Button>
|
||||||
>
|
</ActionItem>
|
||||||
<PencilAltIcon />
|
</ActionsTd>
|
||||||
</Button>
|
</Tr>
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
</DataListAction>
|
|
||||||
</DataListItemRow>
|
|
||||||
</DataListItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ describe('<HostsListItem />', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<HostsListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
detailUrl="/host/1"
|
<HostsListItem
|
||||||
onSelect={() => {}}
|
isSelected={false}
|
||||||
host={mockHost}
|
detailUrl="/host/1"
|
||||||
/>
|
onSelect={() => {}}
|
||||||
|
host={mockHost}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,12 +50,16 @@ describe('<HostsListItem />', () => {
|
|||||||
const copyMockHost = Object.assign({}, mockHost);
|
const copyMockHost = Object.assign({}, mockHost);
|
||||||
copyMockHost.summary_fields.user_capabilities.edit = false;
|
copyMockHost.summary_fields.user_capabilities.edit = false;
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<HostsListItem
|
<table>
|
||||||
isSelected={false}
|
<tbody>
|
||||||
detailUrl="/host/1"
|
<HostsListItem
|
||||||
onSelect={() => {}}
|
isSelected={false}
|
||||||
host={copyMockHost}
|
detailUrl="/host/1"
|
||||||
/>
|
onSelect={() => {}}
|
||||||
|
host={copyMockHost}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
);
|
);
|
||||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ function InstanceGroupListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function InstanceListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function InventoryGroupHostListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function InventoryGroupItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function InventoryHostGroupItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function InventoryHostItem(props) {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -195,9 +195,6 @@ function InventoryList({ i18n }) {
|
|||||||
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
|
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
|
||||||
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
<HeaderCell>{i18n._(t`Type`)}</HeaderCell>
|
||||||
<HeaderCell>{i18n._(t`Organization`)}</HeaderCell>
|
<HeaderCell>{i18n._(t`Organization`)}</HeaderCell>
|
||||||
<HeaderCell>{i18n._(t`Groups`)}</HeaderCell>
|
|
||||||
<HeaderCell>{i18n._(t`Hosts`)}</HeaderCell>
|
|
||||||
<HeaderCell>{i18n._(t`Sources`)}</HeaderCell>
|
|
||||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ function InventoryListItem({
|
|||||||
{inventory?.summary_fields?.organization?.name}
|
{inventory?.summary_fields?.organization?.name}
|
||||||
</Link>
|
</Link>
|
||||||
</Td>
|
</Td>
|
||||||
<Td dataLabel={i18n._(t`Groups`)}>{inventory.total_groups}</Td>
|
|
||||||
<Td dataLabel={i18n._(t`Hosts`)}>{inventory.total_hosts}</Td>
|
|
||||||
<Td dataLabel={i18n._(t`Sources`)}>
|
|
||||||
{inventory.total_inventory_sources}
|
|
||||||
</Td>
|
|
||||||
{inventory.pending_deletion ? (
|
{inventory.pending_deletion ? (
|
||||||
<Td dataLabel={i18n._(t`Groups`)}>
|
<Td dataLabel={i18n._(t`Groups`)}>
|
||||||
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function InventoryRelatedGroupListItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<DataListAction
|
<DataListAction
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
aria-labelledby={labelId}
|
aria-labelledby={labelId}
|
||||||
id={labelId}
|
id={labelId}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function InventorySourceListItem({
|
|||||||
<DataListAction
|
<DataListAction
|
||||||
id="actions"
|
id="actions"
|
||||||
aria-labelledby="actions"
|
aria-labelledby="actions"
|
||||||
aria-label="actions"
|
aria-label={i18n._(t`actions`)}
|
||||||
>
|
>
|
||||||
{source.summary_fields.user_capabilities.start && (
|
{source.summary_fields.user_capabilities.start && (
|
||||||
<InventorySourceSyncButton source={source} />
|
<InventorySourceSyncButton source={source} />
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ function SmartInventoryDetail({ inventory, i18n }) {
|
|||||||
{user_capabilities?.edit && (
|
{user_capabilities?.edit && (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
aria-label="edit"
|
aria-label={i18n._(t`edit`)}
|
||||||
to={`/inventories/smart_inventory/${id}/edit`}
|
to={`/inventories/smart_inventory/${id}/edit`}
|
||||||
>
|
>
|
||||||
{i18n._(t`Edit`)}
|
{i18n._(t`Edit`)}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const InventoryGroupsDeleteModal = ({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={i18n._(t`Close`)}
|
aria-label={i18n._(t`Close`)}
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => setIsModalOpen(false)}
|
||||||
variant="secondary"
|
variant="link"
|
||||||
key="cancel"
|
key="cancel"
|
||||||
>
|
>
|
||||||
{i18n._(t`Cancel`)}
|
{i18n._(t`Cancel`)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DetailList,
|
DetailList,
|
||||||
Detail,
|
Detail,
|
||||||
UserDateDetail,
|
UserDateDetail,
|
||||||
|
LaunchedByDetail,
|
||||||
} from '../../../components/DetailList';
|
} from '../../../components/DetailList';
|
||||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||||
import ChipGroup from '../../../components/ChipGroup';
|
import ChipGroup from '../../../components/ChipGroup';
|
||||||
@@ -53,35 +54,6 @@ const VERBOSITY = {
|
|||||||
4: '4 (Connection Debug)',
|
4: '4 (Connection Debug)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
|
||||||
const {
|
|
||||||
created_by: createdBy,
|
|
||||||
job_template: jobTemplate,
|
|
||||||
schedule,
|
|
||||||
} = summary_fields;
|
|
||||||
const { schedule: relatedSchedule } = related;
|
|
||||||
|
|
||||||
if (!createdBy && !schedule) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let link;
|
|
||||||
let value;
|
|
||||||
|
|
||||||
if (createdBy) {
|
|
||||||
link = `/users/${createdBy.id}`;
|
|
||||||
value = createdBy.username;
|
|
||||||
} else if (relatedSchedule && jobTemplate) {
|
|
||||||
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
|
|
||||||
value = schedule.name;
|
|
||||||
} else {
|
|
||||||
link = null;
|
|
||||||
value = schedule.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { link, value };
|
|
||||||
};
|
|
||||||
|
|
||||||
function JobDetail({ job, i18n }) {
|
function JobDetail({ job, i18n }) {
|
||||||
const {
|
const {
|
||||||
created_by,
|
created_by,
|
||||||
@@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) {
|
|||||||
workflow_job: i18n._(t`Workflow Job`),
|
workflow_job: i18n._(t`Workflow Job`),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { value: launchedByValue, link: launchedByLink } =
|
|
||||||
getLaunchedByDetails(job) || {};
|
|
||||||
|
|
||||||
const deleteJob = async () => {
|
const deleteJob = async () => {
|
||||||
try {
|
try {
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
@@ -137,7 +106,7 @@ function JobDetail({ job, i18n }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isIsolatedInstanceGroup = item => {
|
const buildInstanceGroupLink = item => {
|
||||||
if (item.is_isolated) {
|
if (item.is_isolated) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -153,16 +122,26 @@ function JobDetail({ job, i18n }) {
|
|||||||
return <Link to={`/instance_groups/${item.id}`}>{item.name}</Link>;
|
return <Link to={`/instance_groups/${item.id}`}>{item.name}</Link>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildContainerGroupLink = item => {
|
||||||
|
return (
|
||||||
|
<Link to={`/instance_groups/container_group/${item.id}`}>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
{/* TODO: hookup status to websockets */}
|
|
||||||
<Detail
|
<Detail
|
||||||
|
fullWidth={Boolean(job.job_explanation)}
|
||||||
label={i18n._(t`Status`)}
|
label={i18n._(t`Status`)}
|
||||||
value={
|
value={
|
||||||
<StatusDetailValue>
|
<StatusDetailValue>
|
||||||
{job.status && <StatusIcon status={job.status} />}
|
{job.status && <StatusIcon status={job.status} />}
|
||||||
{toTitleCase(job.status)}
|
{job.job_explanation
|
||||||
|
? job.job_explanation
|
||||||
|
: toTitleCase(job.status)}
|
||||||
</StatusDetailValue>
|
</StatusDetailValue>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -207,16 +186,7 @@ function JobDetail({ job, i18n }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
|
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
|
||||||
<Detail
|
<LaunchedByDetail job={job} i18n={i18n} />
|
||||||
label={i18n._(t`Launched By`)}
|
|
||||||
value={
|
|
||||||
launchedByLink ? (
|
|
||||||
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
|
||||||
) : (
|
|
||||||
launchedByValue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{inventory && (
|
{inventory && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Inventory`)}
|
label={i18n._(t`Inventory`)}
|
||||||
@@ -250,10 +220,16 @@ function JobDetail({ job, i18n }) {
|
|||||||
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
|
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
|
||||||
<Detail label={i18n._(t`Environment`)} value={job.custom_virtualenv} />
|
<Detail label={i18n._(t`Environment`)} value={job.custom_virtualenv} />
|
||||||
<Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
|
<Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
|
||||||
{instanceGroup && (
|
{instanceGroup && !instanceGroup?.is_containerized && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Instance Group`)}
|
label={i18n._(t`Instance Group`)}
|
||||||
value={isIsolatedInstanceGroup(instanceGroup)}
|
value={buildInstanceGroupLink(instanceGroup)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{instanceGroup && instanceGroup?.is_containerized && (
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Container Group`)}
|
||||||
|
value={buildContainerGroupLink(instanceGroup)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{typeof job.job_slice_number === 'number' &&
|
{typeof job.job_slice_number === 'number' &&
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
import React, { Component, Fragment } from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
import { I18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
InfiniteLoader,
|
InfiniteLoader,
|
||||||
List,
|
List,
|
||||||
} from 'react-virtualized';
|
} from 'react-virtualized';
|
||||||
|
import { Button } from '@patternfly/react-core';
|
||||||
import Ansi from 'ansi-to-html';
|
import Ansi from 'ansi-to-html';
|
||||||
import hasAnsi from 'has-ansi';
|
import hasAnsi from 'has-ansi';
|
||||||
import { AllHtmlEntities } from 'html-entities';
|
import { AllHtmlEntities } from 'html-entities';
|
||||||
@@ -225,6 +226,7 @@ class JobOutput extends Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
contentError: null,
|
contentError: null,
|
||||||
deletionError: null,
|
deletionError: null,
|
||||||
|
cancelError: null,
|
||||||
hasContentLoading: true,
|
hasContentLoading: true,
|
||||||
results: {},
|
results: {},
|
||||||
currentlyLoading: [],
|
currentlyLoading: [],
|
||||||
@@ -232,6 +234,9 @@ class JobOutput extends Component {
|
|||||||
isHostModalOpen: false,
|
isHostModalOpen: false,
|
||||||
hostEvent: {},
|
hostEvent: {},
|
||||||
cssMap: {},
|
cssMap: {},
|
||||||
|
jobStatus: props.job.status ?? 'waiting',
|
||||||
|
showCancelPrompt: false,
|
||||||
|
cancelInProgress: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cache = new CellMeasurerCache({
|
this.cache = new CellMeasurerCache({
|
||||||
@@ -242,6 +247,9 @@ class JobOutput extends Component {
|
|||||||
this._isMounted = false;
|
this._isMounted = false;
|
||||||
this.loadJobEvents = this.loadJobEvents.bind(this);
|
this.loadJobEvents = this.loadJobEvents.bind(this);
|
||||||
this.handleDeleteJob = this.handleDeleteJob.bind(this);
|
this.handleDeleteJob = this.handleDeleteJob.bind(this);
|
||||||
|
this.handleCancelOpen = this.handleCancelOpen.bind(this);
|
||||||
|
this.handleCancelConfirm = this.handleCancelConfirm.bind(this);
|
||||||
|
this.handleCancelClose = this.handleCancelClose.bind(this);
|
||||||
this.rowRenderer = this.rowRenderer.bind(this);
|
this.rowRenderer = this.rowRenderer.bind(this);
|
||||||
this.handleHostEventClick = this.handleHostEventClick.bind(this);
|
this.handleHostEventClick = this.handleHostEventClick.bind(this);
|
||||||
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
||||||
@@ -261,11 +269,21 @@ class JobOutput extends Component {
|
|||||||
this._isMounted = true;
|
this._isMounted = true;
|
||||||
this.loadJobEvents();
|
this.loadJobEvents();
|
||||||
|
|
||||||
|
if (job.result_traceback) return;
|
||||||
|
|
||||||
connectJobSocket(job, data => {
|
connectJobSocket(job, data => {
|
||||||
if (data.counter && data.counter > this.jobSocketCounter) {
|
if (data.group_name === 'job_events') {
|
||||||
this.jobSocketCounter = data.counter;
|
if (data.counter && data.counter > this.jobSocketCounter) {
|
||||||
} else if (data.final_counter && data.unified_job_id === job.id) {
|
this.jobSocketCounter = data.counter;
|
||||||
this.jobSocketCounter = data.final_counter;
|
}
|
||||||
|
}
|
||||||
|
if (data.group_name === 'jobs' && data.unified_job_id === job.id) {
|
||||||
|
if (data.final_counter) {
|
||||||
|
this.jobSocketCounter = data.final_counter;
|
||||||
|
}
|
||||||
|
if (data.status) {
|
||||||
|
this.setState({ jobStatus: data.status });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000);
|
this.interval = setInterval(() => this.monitorJobSocketCounter(), 5000);
|
||||||
@@ -326,10 +344,32 @@ class JobOutput extends Component {
|
|||||||
});
|
});
|
||||||
this._isMounted &&
|
this._isMounted &&
|
||||||
this.setState(({ results }) => {
|
this.setState(({ results }) => {
|
||||||
|
let countOffset = 1;
|
||||||
|
if (job?.result_traceback) {
|
||||||
|
const tracebackEvent = {
|
||||||
|
counter: 1,
|
||||||
|
created: null,
|
||||||
|
event: null,
|
||||||
|
type: null,
|
||||||
|
stdout: job?.result_traceback,
|
||||||
|
start_line: 0,
|
||||||
|
};
|
||||||
|
const firstIndex = newResults.findIndex(
|
||||||
|
jobEvent => jobEvent.counter === 1
|
||||||
|
);
|
||||||
|
if (firstIndex && newResults[firstIndex]?.stdout) {
|
||||||
|
const stdoutLines = newResults[firstIndex].stdout.split('\r\n');
|
||||||
|
stdoutLines[0] = tracebackEvent.stdout;
|
||||||
|
newResults[firstIndex].stdout = stdoutLines.join('\r\n');
|
||||||
|
} else {
|
||||||
|
countOffset += 1;
|
||||||
|
newResults.unshift(tracebackEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
newResults.forEach(jobEvent => {
|
newResults.forEach(jobEvent => {
|
||||||
results[jobEvent.counter] = jobEvent;
|
results[jobEvent.counter] = jobEvent;
|
||||||
});
|
});
|
||||||
return { results, remoteRowCount: count + 1 };
|
return { results, remoteRowCount: count + countOffset };
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ contentError: err });
|
this.setState({ contentError: err });
|
||||||
@@ -344,6 +384,26 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleCancelOpen() {
|
||||||
|
this.setState({ showCancelPrompt: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelClose() {
|
||||||
|
this.setState({ showCancelPrompt: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCancelConfirm() {
|
||||||
|
const { job, type } = this.props;
|
||||||
|
this.setState({ cancelInProgress: true });
|
||||||
|
try {
|
||||||
|
await JobsAPI.cancel(job.id, type);
|
||||||
|
} catch (cancelError) {
|
||||||
|
this.setState({ cancelError });
|
||||||
|
} finally {
|
||||||
|
this.setState({ showCancelPrompt: false, cancelInProgress: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleDeleteJob() {
|
async handleDeleteJob() {
|
||||||
const { job, history } = this.props;
|
const { job, history } = this.props;
|
||||||
try {
|
try {
|
||||||
@@ -518,7 +578,7 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { job } = this.props;
|
const { job, i18n } = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contentError,
|
contentError,
|
||||||
@@ -528,6 +588,10 @@ class JobOutput extends Component {
|
|||||||
isHostModalOpen,
|
isHostModalOpen,
|
||||||
remoteRowCount,
|
remoteRowCount,
|
||||||
cssMap,
|
cssMap,
|
||||||
|
jobStatus,
|
||||||
|
showCancelPrompt,
|
||||||
|
cancelError,
|
||||||
|
cancelInProgress,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
if (hasContentLoading) {
|
if (hasContentLoading) {
|
||||||
@@ -553,7 +617,12 @@ class JobOutput extends Component {
|
|||||||
<StatusIcon status={job.status} />
|
<StatusIcon status={job.status} />
|
||||||
<h1>{job.name}</h1>
|
<h1>{job.name}</h1>
|
||||||
</HeaderTitle>
|
</HeaderTitle>
|
||||||
<OutputToolbar job={job} onDelete={this.handleDeleteJob} />
|
<OutputToolbar
|
||||||
|
job={job}
|
||||||
|
jobStatus={jobStatus}
|
||||||
|
onDelete={this.handleDeleteJob}
|
||||||
|
onCancel={this.handleCancelOpen}
|
||||||
|
/>
|
||||||
</OutputHeader>
|
</OutputHeader>
|
||||||
<HostStatusBar counts={job.host_status_counts} />
|
<HostStatusBar counts={job.host_status_counts} />
|
||||||
<PageControls
|
<PageControls
|
||||||
@@ -595,21 +664,65 @@ class JobOutput extends Component {
|
|||||||
<OutputFooter />
|
<OutputFooter />
|
||||||
</OutputWrapper>
|
</OutputWrapper>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
{showCancelPrompt &&
|
||||||
|
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
||||||
|
<AlertModal
|
||||||
|
isOpen={showCancelPrompt}
|
||||||
|
variant="danger"
|
||||||
|
onClose={this.handleCancelClose}
|
||||||
|
title={i18n._(t`Cancel Job`)}
|
||||||
|
label={i18n._(t`Cancel Job`)}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
id="cancel-job-confirm-button"
|
||||||
|
key="delete"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={cancelInProgress}
|
||||||
|
aria-label={i18n._(t`Cancel job`)}
|
||||||
|
onClick={this.handleCancelConfirm}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel job`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
id="cancel-job-return-button"
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Return`)}
|
||||||
|
onClick={this.handleCancelClose}
|
||||||
|
>
|
||||||
|
{i18n._(t`Return`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{i18n._(
|
||||||
|
t`Are you sure you want to submit the request to cancel this job?`
|
||||||
|
)}
|
||||||
|
</AlertModal>
|
||||||
|
)}
|
||||||
|
{cancelError && (
|
||||||
|
<>
|
||||||
|
<AlertModal
|
||||||
|
isOpen={cancelError}
|
||||||
|
variant="danger"
|
||||||
|
onClose={() => this.setState({ cancelError: null })}
|
||||||
|
title={i18n._(t`Job Cancel Error`)}
|
||||||
|
label={i18n._(t`Job Cancel Error`)}
|
||||||
|
>
|
||||||
|
<ErrorDetail error={cancelError} />
|
||||||
|
</AlertModal>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{deletionError && (
|
{deletionError && (
|
||||||
<>
|
<>
|
||||||
<I18n>
|
<AlertModal
|
||||||
{({ i18n }) => (
|
isOpen={deletionError}
|
||||||
<AlertModal
|
variant="danger"
|
||||||
isOpen={deletionError}
|
onClose={() => this.setState({ deletionError: null })}
|
||||||
variant="danger"
|
title={i18n._(t`Job Delete Error`)}
|
||||||
onClose={() => this.setState({ deletionError: null })}
|
label={i18n._(t`Job Delete Error`)}
|
||||||
title={i18n._(t`Job Delete Error`)}
|
>
|
||||||
label={i18n._(t`Job Delete Error`)}
|
<ErrorDetail error={deletionError} />
|
||||||
>
|
</AlertModal>
|
||||||
<ErrorDetail error={deletionError} />
|
|
||||||
</AlertModal>
|
|
||||||
)}
|
|
||||||
</I18n>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -618,4 +731,4 @@ class JobOutput extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { JobOutput as _JobOutput };
|
export { JobOutput as _JobOutput };
|
||||||
export default withRouter(JobOutput);
|
export default withI18n()(withRouter(JobOutput));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { shape, func } from 'prop-types';
|
import { shape, func } from 'prop-types';
|
||||||
import {
|
import {
|
||||||
|
MinusCircleIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
TrashAltIcon,
|
TrashAltIcon,
|
||||||
@@ -58,7 +59,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
|
|||||||
'inventory_update',
|
'inventory_update',
|
||||||
];
|
];
|
||||||
|
|
||||||
const OutputToolbar = ({ i18n, job, onDelete }) => {
|
const OutputToolbar = ({ i18n, job, jobStatus, onDelete, onCancel }) => {
|
||||||
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
const hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(job.type);
|
||||||
|
|
||||||
const playCount = job?.playbook_counts?.play_count;
|
const playCount = job?.playbook_counts?.play_count;
|
||||||
@@ -148,19 +149,34 @@ const OutputToolbar = ({ i18n, job, onDelete }) => {
|
|||||||
</a>
|
</a>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{job.summary_fields.user_capabilities.start &&
|
||||||
|
['pending', 'waiting', 'running'].includes(jobStatus) && (
|
||||||
|
<Tooltip content={i18n._(t`Cancel Job`)}>
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
aria-label={i18n._(t`Cancel Job`)}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<MinusCircleIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
{job.summary_fields.user_capabilities.delete && (
|
{job.summary_fields.user_capabilities.delete &&
|
||||||
<Tooltip content={i18n._(t`Delete Job`)}>
|
['new', 'successful', 'failed', 'error', 'canceled'].includes(
|
||||||
<DeleteButton
|
jobStatus
|
||||||
name={job.name}
|
) && (
|
||||||
modalTitle={i18n._(t`Delete Job`)}
|
<Tooltip content={i18n._(t`Delete Job`)}>
|
||||||
onConfirm={onDelete}
|
<DeleteButton
|
||||||
variant="plain"
|
name={job.name}
|
||||||
>
|
modalTitle={i18n._(t`Delete Job`)}
|
||||||
<TrashAltIcon />
|
onConfirm={onDelete}
|
||||||
</DeleteButton>
|
variant="plain"
|
||||||
</Tooltip>
|
>
|
||||||
)}
|
<TrashAltIcon />
|
||||||
|
</DeleteButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
failures: 2,
|
failures: 2,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -33,6 +34,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<OutputToolbar
|
<OutputToolbar
|
||||||
job={{ ...mockJobData, type: 'system_job' }}
|
job={{ ...mockJobData, type: 'system_job' }}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -54,6 +56,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
host_status_counts: {},
|
host_status_counts: {},
|
||||||
playbook_counts: {},
|
playbook_counts: {},
|
||||||
}}
|
}}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -74,6 +77,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
...mockJobData,
|
...mockJobData,
|
||||||
elapsed: 274265,
|
elapsed: 274265,
|
||||||
}}
|
}}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -95,6 +99,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -113,6 +118,7 @@ describe('<OutputToolbar />', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
jobStatus="successful"
|
||||||
onDelete={() => {}}
|
onDelete={() => {}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user