Merge branch 'fix-awx_collection-docs' of github.com:sean-m-sullivan/awx into fix-awx_collection-docs

This commit is contained in:
sean-m-sullivan
2021-02-10 15:45:38 -06:00
176 changed files with 6352 additions and 3269 deletions

View File

@@ -1,5 +1,7 @@
[![Gated by Zuul](https://zuul-ci.org/gated.svg)](https://ansible.softwarefactory-project.io/zuul/status) [![Gated by Zuul](https://zuul-ci.org/gated.svg)](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).

View File

@@ -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):

View File

@@ -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',

View File

@@ -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
@@ -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)

View File

@@ -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:

View File

@@ -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')

View File

@@ -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

View File

@@ -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'] == ''

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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
############################################################################### ###############################################################################

View File

@@ -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',

View File

@@ -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",

View File

@@ -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
View 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')

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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`)}

View File

@@ -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');

View File

@@ -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`)}

View File

@@ -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);

View File

@@ -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');
}); });

View File

@@ -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"

View File

@@ -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)}
> >

View 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
)
}
/>
);
}

View File

@@ -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

View File

@@ -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)}
> >

View File

@@ -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}
> >

View File

@@ -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}
/> />
)} )}
/> />

View File

@@ -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} &mdash; {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>&mdash;</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>
</>
); );
} }

View File

@@ -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);
}); });
}); });

View File

@@ -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"

View File

@@ -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

View File

@@ -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 (

View File

@@ -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>,
]} ]}

View File

@@ -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}

View File

@@ -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}
> >

View File

@@ -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,

View File

@@ -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>
);
} }

View File

@@ -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);
});
}); });

View File

@@ -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>
); );
} }

View File

@@ -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>
</> </>
)} )}
</> </>

View File

@@ -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>,
]} ]}

View File

@@ -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}

View File

@@ -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

View File

@@ -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({

View File

@@ -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 => (

View File

@@ -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();

View File

@@ -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>
); );
} }

View File

@@ -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

View File

@@ -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}

View File

@@ -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 = [
{ {

View 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);

View File

@@ -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);
});
});

View 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"
}

View File

@@ -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';

View File

@@ -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}
> >

View File

@@ -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 => (

View File

@@ -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();

View File

@@ -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>
); );
} }

View File

@@ -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);
}); });

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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}
> >

View File

@@ -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={

View File

@@ -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();

View File

@@ -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>
); );
} }

View File

@@ -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();
}); });

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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}
> >

View File

@@ -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>
} }

View File

@@ -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>

View File

@@ -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}
> >

View File

@@ -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} />

View File

@@ -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`)}

View File

@@ -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`)}

View File

@@ -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' &&

View File

@@ -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));

View File

@@ -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>
); );
}; };

View File

@@ -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