mirror of
https://github.com/ansible/awx.git
synced 2026-01-10 15:32:07 -03:30
Merge branch 'fix-awx_collection-docs' of github.com:sean-m-sullivan/awx into fix-awx_collection-docs
This commit is contained in:
commit
ed8b4a3c70
@ -1,5 +1,7 @@
|
||||
[](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.
|
||||
|
||||
To install AWX, please view the [Install guide](./INSTALL.md).
|
||||
|
||||
@ -828,6 +828,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
return self.inventory.hosts.only(*only)
|
||||
|
||||
def start_job_fact_cache(self, destination, modification_times, timeout=None):
|
||||
self.log_lifecycle("start_job_fact_cache")
|
||||
os.makedirs(destination, mode=0o700)
|
||||
hosts = self._get_inventory_hosts()
|
||||
if timeout is None:
|
||||
@ -852,6 +853,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
modification_times[filepath] = os.path.getmtime(filepath)
|
||||
|
||||
def finish_job_fact_cache(self, destination, modification_times):
|
||||
self.log_lifecycle("finish_job_fact_cache")
|
||||
for host in self._get_inventory_hosts():
|
||||
filepath = os.sep.join(map(str, [destination, host.name]))
|
||||
if not os.path.realpath(filepath).startswith(destination):
|
||||
|
||||
@ -280,6 +280,7 @@ class JobNotificationMixin(object):
|
||||
{'unified_job_template': ['id', 'name', 'description', 'unified_job_type']},
|
||||
{'instance_group': ['name', 'id']},
|
||||
{'created_by': ['id', 'username', 'first_name', 'last_name']},
|
||||
{'schedule': ['id', 'name', 'description', 'next_run']},
|
||||
{'labels': ['count', 'results']}]}]
|
||||
|
||||
@classmethod
|
||||
@ -344,6 +345,10 @@ class JobNotificationMixin(object):
|
||||
'name': 'Stub project',
|
||||
'scm_type': 'git',
|
||||
'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',
|
||||
'id': 39,
|
||||
'name': 'Stub Job Template',
|
||||
|
||||
@ -55,7 +55,7 @@ from awx.main.fields import JSONField, AskForField, OrderedManyToManyField
|
||||
__all__ = ['UnifiedJobTemplate', 'UnifiedJob', 'StdoutMaxBytesExceeded']
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -420,7 +420,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
|
||||
# have been associated to the UJ
|
||||
if unified_job.__class__ in activity_stream_registrar.models:
|
||||
activity_stream_create(None, unified_job, True)
|
||||
|
||||
unified_job.log_lifecycle("created")
|
||||
return unified_job
|
||||
|
||||
@classmethod
|
||||
@ -862,7 +862,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
self.unified_job_template = self._get_parent_instance()
|
||||
if 'unified_job_template' not in update_fields:
|
||||
update_fields.append('unified_job_template')
|
||||
|
||||
|
||||
if self.cancel_flag and not self.canceled_on:
|
||||
# Record the 'canceled' time.
|
||||
self.canceled_on = now()
|
||||
@ -1010,6 +1010,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
event_qs = self.get_event_queryset()
|
||||
except NotImplementedError:
|
||||
return True # Model without events, such as WFJT
|
||||
self.log_lifecycle("event_processing_finished")
|
||||
return self.emitted_events == event_qs.count()
|
||||
|
||||
def result_stdout_raw_handle(self, enforce_max_bytes=True):
|
||||
@ -1318,6 +1319,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
if 'extra_vars' in kwargs:
|
||||
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)
|
||||
|
||||
def signal_start(self, **kwargs):
|
||||
@ -1484,3 +1489,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
@property
|
||||
def is_containerized(self):
|
||||
return False
|
||||
|
||||
def log_lifecycle(self, state, blocked_by=None):
|
||||
extra={'type': self._meta.model_name,
|
||||
'task_id': self.id,
|
||||
'state': state}
|
||||
if self.unified_job_template:
|
||||
extra["template_name"] = self.unified_job_template.name
|
||||
if state == "blocked" and blocked_by:
|
||||
blocked_by_msg = f"{blocked_by._meta.model_name}-{blocked_by.id}"
|
||||
msg = f"{self._meta.model_name}-{self.id} blocked by {blocked_by_msg}"
|
||||
extra["blocked_by"] = blocked_by_msg
|
||||
else:
|
||||
msg = f"{self._meta.model_name}-{self.id} {state.replace('_', ' ')}"
|
||||
logger_job_lifecycle.debug(msg, extra=extra)
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
from django.utils.timezone import now as tz_now
|
||||
|
||||
from awx.main.models import (
|
||||
Job,
|
||||
ProjectUpdate,
|
||||
@ -20,119 +18,110 @@ class DependencyGraph(object):
|
||||
INVENTORY_SOURCE_UPDATES = 'inventory_source_updates'
|
||||
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'
|
||||
|
||||
def __init__(self, queue):
|
||||
self.queue = queue
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
# project_id -> True / False
|
||||
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] = {}
|
||||
# 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] = {}
|
||||
# True / False
|
||||
self.data[self.SYSTEM_JOB] = True
|
||||
# workflow_job_template_id -> True / False
|
||||
self.data[self.JOB_TEMPLATE_JOBS] = {}
|
||||
self.data[self.SYSTEM_JOB] = {}
|
||||
self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS] = {}
|
||||
|
||||
# project_id -> latest ProjectUpdateLatestDict'
|
||||
self.data[self.LATEST_PROJECT_UPDATES] = {}
|
||||
# inventory_source_id -> latest InventoryUpdateLatestDict
|
||||
self.data[self.LATEST_INVENTORY_UPDATES] = {}
|
||||
def mark_if_no_key(self, job_type, id, job):
|
||||
# only mark first occurrence of a task. If 10 of JobA are launched
|
||||
# (concurrent disabled), the dependency graph should return that jobs
|
||||
# 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]
|
||||
self.data[self.INVENTORY_SOURCES] = {}
|
||||
def get_item(self, job_type, id):
|
||||
return self.data[job_type].get(id, None)
|
||||
|
||||
def add_latest_project_update(self, job):
|
||||
self.data[self.LATEST_PROJECT_UPDATES][job.project_id] = job
|
||||
|
||||
def get_now(self):
|
||||
return tz_now()
|
||||
|
||||
def mark_system_job(self):
|
||||
self.data[self.SYSTEM_JOB] = False
|
||||
def mark_system_job(self, 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'.
|
||||
self.mark_if_no_key(self.SYSTEM_JOB, 'system_job', 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):
|
||||
self.data[self.INVENTORY_UPDATES][inventory_id] = False
|
||||
def mark_inventory_update(self, job):
|
||||
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):
|
||||
self.data[self.INVENTORY_SOURCE_UPDATES][inventory_source_id] = False
|
||||
def mark_inventory_source_update(self, job):
|
||||
self.mark_if_no_key(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id, 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):
|
||||
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):
|
||||
return self.data[self.PROJECT_UPDATES].get(job.project_id, True)
|
||||
def project_update_blocked_by(self, job):
|
||||
return self.get_item(self.PROJECT_UPDATES, job.project_id)
|
||||
|
||||
def can_inventory_update_run(self, job):
|
||||
return self.data[self.INVENTORY_SOURCE_UPDATES].get(job.inventory_source_id, True)
|
||||
def inventory_update_blocked_by(self, job):
|
||||
return self.get_item(self.INVENTORY_SOURCE_UPDATES, job.inventory_source_id)
|
||||
|
||||
def can_job_run(self, job):
|
||||
if self.data[self.PROJECT_UPDATES].get(job.project_id, True) is True and \
|
||||
self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True) is True:
|
||||
if job.allow_simultaneous is False:
|
||||
return self.data[self.JOB_TEMPLATE_JOBS].get(job.job_template_id, True)
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
def job_blocked_by(self, job):
|
||||
project_block = self.get_item(self.PROJECT_UPDATES, job.project_id)
|
||||
inventory_block = self.get_item(self.INVENTORY_UPDATES, job.inventory_id)
|
||||
if job.allow_simultaneous is False:
|
||||
job_block = self.get_item(self.JOB_TEMPLATE_JOBS, job.job_template_id)
|
||||
else:
|
||||
job_block = None
|
||||
return project_block or inventory_block or job_block
|
||||
|
||||
def can_workflow_job_run(self, job):
|
||||
if job.allow_simultaneous:
|
||||
return True
|
||||
return self.data[self.WORKFLOW_JOB_TEMPLATES_JOBS].get(job.workflow_job_template_id, True)
|
||||
def workflow_job_blocked_by(self, job):
|
||||
if job.allow_simultaneous is False:
|
||||
return self.get_item(self.WORKFLOW_JOB_TEMPLATES_JOBS, job.workflow_job_template_id)
|
||||
return None
|
||||
|
||||
def can_system_job_run(self):
|
||||
return self.data[self.SYSTEM_JOB]
|
||||
def system_job_blocked_by(self, job):
|
||||
return self.get_item(self.SYSTEM_JOB, 'system_job')
|
||||
|
||||
def can_ad_hoc_command_run(self, job):
|
||||
return self.data[self.INVENTORY_UPDATES].get(job.inventory_id, True)
|
||||
def ad_hoc_command_blocked_by(self, job):
|
||||
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:
|
||||
return not self.can_project_update_run(job)
|
||||
return self.project_update_blocked_by(job)
|
||||
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:
|
||||
return not self.can_job_run(job)
|
||||
return self.job_blocked_by(job)
|
||||
elif type(job) is SystemJob:
|
||||
return not self.can_system_job_run()
|
||||
return self.system_job_blocked_by(job)
|
||||
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:
|
||||
return not self.can_workflow_job_run(job)
|
||||
return self.workflow_job_blocked_by(job)
|
||||
|
||||
def add_job(self, job):
|
||||
if type(job) is ProjectUpdate:
|
||||
self.mark_project_update(job)
|
||||
elif type(job) is InventoryUpdate:
|
||||
self.mark_inventory_update(job.inventory_source.inventory_id)
|
||||
self.mark_inventory_source_update(job.inventory_source_id)
|
||||
self.mark_inventory_update(job)
|
||||
self.mark_inventory_source_update(job)
|
||||
elif type(job) is Job:
|
||||
self.mark_job_template_job(job)
|
||||
elif type(job) is WorkflowJob:
|
||||
self.mark_workflow_job(job)
|
||||
elif type(job) is SystemJob:
|
||||
self.mark_system_job()
|
||||
self.mark_system_job(job)
|
||||
elif type(job) is AdHocCommand:
|
||||
self.mark_inventory_update(job.inventory_id)
|
||||
self.mark_inventory_update(job)
|
||||
|
||||
def add_jobs(self, jobs):
|
||||
for j in jobs:
|
||||
|
||||
@ -64,6 +64,8 @@ class TaskManager():
|
||||
# will no longer be started and will be started on the next task manager cycle.
|
||||
self.start_task_limit = settings.START_TASK_LIMIT
|
||||
|
||||
self.time_delta_job_explanation = timedelta(seconds=30)
|
||||
|
||||
def after_lock_init(self):
|
||||
'''
|
||||
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}
|
||||
|
||||
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,
|
||||
consumed_capacity=0,
|
||||
instances=[])
|
||||
@ -88,18 +90,21 @@ class TaskManager():
|
||||
if instance.hostname in instances_by_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
|
||||
# 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
|
||||
for g in self.graph:
|
||||
if self.graph[g]['graph'].is_job_blocked(task):
|
||||
return True
|
||||
blocked_by = self.graph[g]['graph'].task_blocked_by(task)
|
||||
if blocked_by:
|
||||
return blocked_by
|
||||
|
||||
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')):
|
||||
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():
|
||||
task.celery_task_id = str(uuid.uuid4())
|
||||
task.save()
|
||||
task.log_lifecycle("waiting")
|
||||
|
||||
if rampart_group is not None:
|
||||
self.consume_capacity(task, rampart_group.name)
|
||||
@ -450,6 +456,7 @@ class TaskManager():
|
||||
def generate_dependencies(self, undeped_tasks):
|
||||
created_dependencies = []
|
||||
for task in undeped_tasks:
|
||||
task.log_lifecycle("acknowledged")
|
||||
dependencies = []
|
||||
if not type(task) is Job:
|
||||
continue
|
||||
@ -489,11 +496,18 @@ class TaskManager():
|
||||
|
||||
def process_pending_tasks(self, pending_tasks):
|
||||
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:
|
||||
if self.start_task_limit <= 0:
|
||||
break
|
||||
if self.is_job_blocked(task):
|
||||
logger.debug("{} is blocked from running".format(task.log_format))
|
||||
blocked_by = self.job_blocked_by(task)
|
||||
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
|
||||
preferred_instance_groups = task.preferred_instance_groups
|
||||
found_acceptable_queue = False
|
||||
@ -539,7 +553,17 @@ class TaskManager():
|
||||
logger.debug("No instance available in group {} to run job {} w/ capacity requirement {}".format(
|
||||
rampart_group.name, task.log_format, task.task_impact))
|
||||
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))
|
||||
UnifiedJob.objects.bulk_update(tasks_to_update_job_explanation, ['job_explanation'])
|
||||
|
||||
def timeout_approval_node(self):
|
||||
workflow_approvals = WorkflowApproval.objects.filter(status='pending')
|
||||
|
||||
@ -336,6 +336,8 @@ def send_notifications(notification_list, job_id=None):
|
||||
sent = notification.notification_template.send(notification.subject, notification.body)
|
||||
notification.status = "successful"
|
||||
notification.notifications_sent = sent
|
||||
if job_id is not None:
|
||||
job_actual.log_lifecycle("notifications_sent")
|
||||
except Exception as e:
|
||||
logger.exception("Send Notification Failed {}".format(e))
|
||||
notification.status = "failed"
|
||||
@ -1186,16 +1188,19 @@ class BaseTask(object):
|
||||
'''
|
||||
Hook for any steps to run before the job/task starts
|
||||
'''
|
||||
instance.log_lifecycle("pre_run")
|
||||
|
||||
def post_run_hook(self, instance, status):
|
||||
'''
|
||||
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):
|
||||
'''
|
||||
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')
|
||||
awx_profiling_dir = '/var/log/tower/playbook_profiling/'
|
||||
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 = self.update_model(pk, status='running',
|
||||
start_args='') # blank field to remove encrypted passwords
|
||||
|
||||
self.instance.websocket_emit_status("running")
|
||||
status, rc = 'error', None
|
||||
extra_update_fields = {}
|
||||
@ -1383,6 +1387,7 @@ class BaseTask(object):
|
||||
self.instance.send_notification_templates("running")
|
||||
private_data_dir = self.build_private_data_dir(self.instance)
|
||||
self.pre_run_hook(self.instance, private_data_dir)
|
||||
self.instance.log_lifecycle("preparing_playbook")
|
||||
if self.instance.cancel_flag:
|
||||
self.instance = self.update_model(self.instance.pk, status='canceled')
|
||||
if self.instance.status != 'running':
|
||||
@ -1510,6 +1515,7 @@ class BaseTask(object):
|
||||
res = ansible_runner.interface.run(**params)
|
||||
status = res.status
|
||||
rc = res.rc
|
||||
self.instance.log_lifecycle("running_playbook")
|
||||
|
||||
if status == 'timeout':
|
||||
self.instance.job_explanation = "Job terminated due to timeout"
|
||||
@ -1868,6 +1874,7 @@ class RunJob(BaseTask):
|
||||
return getattr(settings, 'AWX_PROOT_ENABLED', False)
|
||||
|
||||
def pre_run_hook(self, job, private_data_dir):
|
||||
super(RunJob, self).pre_run_hook(job, private_data_dir)
|
||||
if job.inventory is None:
|
||||
error = _('Job could not start because it does not have a valid inventory.')
|
||||
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))
|
||||
|
||||
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
|
||||
if not os.path.exists(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))
|
||||
|
||||
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
|
||||
try:
|
||||
if self.playbook_new_revision:
|
||||
@ -2663,6 +2672,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
return inventory_update.get_extra_credentials()
|
||||
|
||||
def pre_run_hook(self, inventory_update, private_data_dir):
|
||||
super(RunInventoryUpdate, self).pre_run_hook(inventory_update, private_data_dir)
|
||||
source_project = None
|
||||
if inventory_update.inventory_source:
|
||||
source_project = inventory_update.inventory_source.source_project
|
||||
@ -2707,6 +2717,7 @@ class RunInventoryUpdate(BaseTask):
|
||||
RunProjectUpdate.make_local_copy(source_project, private_data_dir)
|
||||
|
||||
def post_run_hook(self, inventory_update, status):
|
||||
super(RunInventoryUpdate, self).post_run_hook(inventory_update, status)
|
||||
if status != 'successful':
|
||||
return # nothing to save, step out of the way to allow error reporting
|
||||
|
||||
|
||||
@ -393,3 +393,43 @@ def test_saml_x509cert_validation(patch, get, admin, headers):
|
||||
}
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_github_settings(get, put, patch, delete, admin):
|
||||
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github'})
|
||||
get(url, user=admin, expect=200)
|
||||
delete(url, user=admin, expect=204)
|
||||
response = get(url, user=admin, expect=200)
|
||||
data = dict(response.data.items())
|
||||
put(url, user=admin, data=data, expect=200)
|
||||
patch(url, user=admin, data={'SOCIAL_AUTH_GITHUB_KEY': '???'}, expect=200)
|
||||
response = get(url, user=admin, expect=200)
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == '???'
|
||||
data.pop('SOCIAL_AUTH_GITHUB_KEY')
|
||||
put(url, user=admin, data=data, expect=200)
|
||||
response = get(url, user=admin, expect=200)
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_KEY'] == ''
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_github_enterprise_settings(get, put, patch, delete, admin):
|
||||
url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'github-enterprise'})
|
||||
get(url, user=admin, expect=200)
|
||||
delete(url, user=admin, expect=204)
|
||||
response = get(url, user=admin, expect=200)
|
||||
data = dict(response.data.items())
|
||||
put(url, user=admin, data=data, expect=200)
|
||||
patch(url, user=admin, data={
|
||||
'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'example.com',
|
||||
'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'example.com',
|
||||
}, expect=200)
|
||||
response = get(url, user=admin, expect=200)
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == 'example.com'
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == 'example.com'
|
||||
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_URL')
|
||||
data.pop('SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL')
|
||||
put(url, user=admin, data=data, expect=200)
|
||||
response = get(url, user=admin, expect=200)
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_URL'] == ''
|
||||
assert response.data['SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL'] == ''
|
||||
|
||||
@ -6,7 +6,7 @@ import pytest
|
||||
|
||||
#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin
|
||||
from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate,
|
||||
SystemJob, WorkflowJob)
|
||||
Schedule, SystemJob, WorkflowJob)
|
||||
from awx.api.serializers import UnifiedJobSerializer
|
||||
|
||||
|
||||
@ -72,6 +72,10 @@ class TestJobNotificationMixin(object):
|
||||
'name': str,
|
||||
'scm_type': str,
|
||||
'status': str},
|
||||
'schedule': {'description': str,
|
||||
'id': int,
|
||||
'name': str,
|
||||
'next_run': datetime.datetime},
|
||||
'unified_job_template': {'description': str,
|
||||
'id': int,
|
||||
'name': str,
|
||||
@ -89,27 +93,27 @@ class TestJobNotificationMixin(object):
|
||||
'workflow_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.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob])
|
||||
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
|
||||
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 = {}
|
||||
if JobClass is InventoryUpdate:
|
||||
kwargs['inventory_source'] = inventory_source
|
||||
@ -121,8 +125,26 @@ class TestJobNotificationMixin(object):
|
||||
job_serialization = UnifiedJobSerializer(job).to_representation(job)
|
||||
|
||||
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
|
||||
def test_context_job_metadata_with_unicode(self):
|
||||
|
||||
@ -348,11 +348,11 @@ def test_job_not_blocking_project_update(default_instance_group, job_template_fa
|
||||
project_update.instance_group = default_instance_group
|
||||
project_update.status = "pending"
|
||||
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)
|
||||
assert not dependency_graph.is_job_blocked(project_update)
|
||||
assert not dependency_graph.task_blocked_by(project_update)
|
||||
|
||||
|
||||
@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.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)
|
||||
assert not dependency_graph.is_job_blocked(inventory_update)
|
||||
assert not dependency_graph.task_blocked_by(inventory_update)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@ -35,16 +35,17 @@ data_loggly = {
|
||||
# Test reconfigure logging settings function
|
||||
# name this whatever you want
|
||||
@pytest.mark.parametrize(
|
||||
'enabled, log_type, host, port, protocol, expected_config', [
|
||||
'enabled, log_type, host, port, protocol, errorfile, expected_config', [
|
||||
(
|
||||
True,
|
||||
'loggly',
|
||||
'http://logs-01.loggly.com/inputs/1fd38090-2af1-4e1e-8d80-492899da0f71/tag/http/',
|
||||
None,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
9000,
|
||||
'udp',
|
||||
'', # empty errorfile
|
||||
'\n'.join([
|
||||
'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
|
||||
@ -64,6 +66,7 @@ data_loggly = {
|
||||
'localhost',
|
||||
9000,
|
||||
'tcp',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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
|
||||
@ -75,9 +78,10 @@ data_loggly = {
|
||||
'https://yoursplunk/services/collector/event',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
None,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
8088,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
8088,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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',
|
||||
8088,
|
||||
None,
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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
|
||||
None,
|
||||
'https',
|
||||
'/var/log/tower/rsyslog.err',
|
||||
'\n'.join([
|
||||
'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()
|
||||
|
||||
@ -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_TYPE', log_type)
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_HOST', host)
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', errorfile)
|
||||
if port:
|
||||
setattr(mock_settings, 'LOG_AGGREGATOR_PORT', port)
|
||||
if protocol:
|
||||
|
||||
@ -18,6 +18,7 @@ def construct_rsyslog_conf_template(settings=settings):
|
||||
timeout = getattr(settings, 'LOG_AGGREGATOR_TCP_TIMEOUT', 5)
|
||||
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('/')
|
||||
error_log_file = getattr(settings, 'LOG_AGGREGATOR_RSYSLOGD_ERROR_LOG_FILE', '')
|
||||
|
||||
if not os.access(spool_directory, os.W_OK):
|
||||
spool_directory = '/var/lib/awx'
|
||||
@ -74,9 +75,10 @@ def construct_rsyslog_conf_template(settings=settings):
|
||||
f'skipverifyhost="{skip_verify}"',
|
||||
'action.resumeRetryCount="-1"',
|
||||
'template="awx"',
|
||||
'errorfile="/var/log/tower/rsyslog.err"',
|
||||
f'action.resumeInterval="{timeout}"'
|
||||
]
|
||||
if error_log_file:
|
||||
params.append(f'errorfile="{error_log_file}"')
|
||||
if parsed.path:
|
||||
path = urlparse.quote(parsed.path[1:], safe='/=')
|
||||
if parsed.query:
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
from copy import copy
|
||||
import json
|
||||
import json_log_formatter
|
||||
import logging
|
||||
import traceback
|
||||
import socket
|
||||
@ -14,6 +15,15 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
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):
|
||||
'''
|
||||
Custom log formatter used for inventory imports
|
||||
|
||||
@ -103,6 +103,15 @@ if settings.COLOR_LOGS is True:
|
||||
from logutils.colorize import 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):
|
||||
message = logging.StreamHandler.format(self, record)
|
||||
|
||||
@ -262,6 +262,7 @@ TEMPLATES = [
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
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.api',
|
||||
'awx.ui',
|
||||
'awx.ui_next',
|
||||
'awx.sso',
|
||||
'solo'
|
||||
]
|
||||
@ -344,6 +346,9 @@ AUTHENTICATION_BACKENDS = (
|
||||
'social_core.backends.github.GithubOAuth2',
|
||||
'social_core.backends.github.GithubOrganizationOAuth2',
|
||||
'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',
|
||||
'awx.sso.backends.SAMLAuth',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
@ -520,6 +525,20 @@ SOCIAL_AUTH_GITHUB_TEAM_SECRET = ''
|
||||
SOCIAL_AUTH_GITHUB_TEAM_ID = ''
|
||||
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_SECRET = ''
|
||||
|
||||
@ -770,6 +789,7 @@ LOG_AGGREGATOR_LEVEL = 'INFO'
|
||||
LOG_AGGREGATOR_MAX_DISK_USAGE_GB = 1
|
||||
LOG_AGGREGATOR_MAX_DISK_USAGE_PATH = '/var/lib/awx'
|
||||
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
|
||||
# If you're encountering issues establishing websockets in clustered Tower,
|
||||
@ -824,6 +844,9 @@ LOGGING = {
|
||||
'dispatcher': {
|
||||
'format': '%(asctime)s %(levelname)-8s %(name)s PID:%(process)d %(message)s',
|
||||
},
|
||||
'job_lifecycle': {
|
||||
'()': 'awx.main.utils.formatters.JobLifeCycleFormatter',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
@ -853,38 +876,30 @@ LOGGING = {
|
||||
},
|
||||
'tower_warnings': {
|
||||
# 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'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'callback_receiver': {
|
||||
# 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'],
|
||||
'filename': os.path.join(LOG_ROOT, 'callback_receiver.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'dispatcher': {
|
||||
# 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'],
|
||||
'filename': os.path.join(LOG_ROOT, 'dispatcher.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'dispatcher',
|
||||
},
|
||||
'wsbroadcast': {
|
||||
# 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'],
|
||||
'filename': os.path.join(LOG_ROOT, 'wsbroadcast.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'celery.beat': {
|
||||
@ -898,48 +913,44 @@ LOGGING = {
|
||||
},
|
||||
'task_system': {
|
||||
# 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'],
|
||||
'filename': os.path.join(LOG_ROOT, 'task_system.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'management_playbooks': {
|
||||
'level': 'DEBUG',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'management_playbooks.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'system_tracking_migrations': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower_system_tracking_migrations.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'rbac_migrations': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filters': ['require_debug_false'],
|
||||
'filename': os.path.join(LOG_ROOT, 'tower_rbac_migrations.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'isolated_manager': {
|
||||
'level': 'WARNING',
|
||||
'class':'logging.handlers.RotatingFileHandler',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filename': os.path.join(LOG_ROOT, 'isolated_manager.log'),
|
||||
'maxBytes': 1024 * 1024 * 5, # 5 MB
|
||||
'backupCount': 5,
|
||||
'formatter':'simple',
|
||||
},
|
||||
'job_lifecycle': {
|
||||
'level': 'DEBUG',
|
||||
'class':'logging.handlers.WatchedFileHandler',
|
||||
'filename': os.path.join(LOG_ROOT, 'job_lifecycle.log'),
|
||||
'formatter': 'job_lifecycle',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
@ -1029,6 +1040,11 @@ LOGGING = {
|
||||
'level': 'INFO',
|
||||
'propagate': False
|
||||
},
|
||||
'awx.analytics.job_lifecycle': {
|
||||
'handlers': ['console', 'job_lifecycle'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False
|
||||
},
|
||||
'django_auth_ldap': {
|
||||
'handlers': ['console', 'file', 'tower_warnings'],
|
||||
'level': 'DEBUG',
|
||||
|
||||
292
awx/sso/conf.py
292
awx/sso/conf.py
@ -842,6 +842,298 @@ register(
|
||||
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
|
||||
###############################################################################
|
||||
|
||||
@ -187,6 +187,26 @@ class AuthenticationBackendsField(fields.StringListField):
|
||||
'SOCIAL_AUTH_GITHUB_TEAM_SECRET',
|
||||
'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_AUTH_AZUREAD_OAUTH2_KEY',
|
||||
'SOCIAL_AUTH_AZUREAD_OAUTH2_SECRET',
|
||||
|
||||
@ -8,8 +8,14 @@
|
||||
"modules": true
|
||||
}
|
||||
},
|
||||
"plugins": ["react-hooks", "jsx-a11y"],
|
||||
"extends": ["airbnb", "prettier", "prettier/react", "plugin:jsx-a11y/strict"],
|
||||
"plugins": ["react-hooks", "jsx-a11y", "i18next"],
|
||||
"extends": [
|
||||
"airbnb",
|
||||
"prettier",
|
||||
"prettier/react",
|
||||
"plugin:jsx-a11y/strict",
|
||||
"plugin:i18next/recommended"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "16.5.2"
|
||||
@ -24,6 +30,70 @@
|
||||
"window": true
|
||||
},
|
||||
"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",
|
||||
"arrow-parens": "off",
|
||||
"comma-dangle": "off",
|
||||
|
||||
@ -62,7 +62,7 @@ The AWX UI requires the following:
|
||||
|
||||
Run the following to install all the dependencies:
|
||||
```bash
|
||||
(host) $ npm run install
|
||||
(host) $ npm install
|
||||
```
|
||||
|
||||
#### Build the User Interface
|
||||
|
||||
10
awx/ui_next/apps.py
Normal file
10
awx/ui_next/apps.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Django
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
||||
class UINextConfig(AppConfig):
|
||||
|
||||
name = 'awx.ui_next'
|
||||
verbose_name = _('UI_Next')
|
||||
|
||||
15
awx/ui_next/package-lock.json
generated
15
awx/ui_next/package-lock.json
generated
@ -7172,6 +7172,15 @@
|
||||
"lodash": "^4.17.15"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.22.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
"eslint-config-airbnb": "^17.1.0",
|
||||
"eslint-config-prettier": "^5.0.0",
|
||||
"eslint-import-resolver-webpack": "0.11.1",
|
||||
"eslint-plugin-i18next": "^5.0.0",
|
||||
"eslint-plugin-import": "^2.14.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.11.1",
|
||||
|
||||
@ -1,25 +1,40 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
setInterval(function() {
|
||||
window.location = '/';
|
||||
}, 10000);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<span>
|
||||
<p>AWX is installing.</p>
|
||||
<p>This page will refresh when complete.</p>
|
||||
</span>
|
||||
</div>
|
||||
</body>
|
||||
<head>
|
||||
<title>{{ title }}</title>
|
||||
<meta
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="{% static 'css/fonts/assets/RedHatDisplay/RedHatDisplay-Medium.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
|
||||
<link href="{% static 'css/fonts/assets/RedHatText/RedHatText-Regular.woff' %}" rel="stylesheet" type="application/font-woff" media="all"/>
|
||||
<link href="{% static 'css/patternfly.min.css' %}" rel="stylesheet" type="text/css" media="all"/>
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
setInterval(function() {
|
||||
window.location = '/';
|
||||
}, 10000);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pf-l-bullseye pf-m-gutter">
|
||||
<div class="pf-l-bullseye__item">
|
||||
<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>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
2
awx/ui_next/public/static/css/patternfly.min.css
vendored
Normal file
2
awx/ui_next/public/static/css/patternfly.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
awx/ui_next/public/static/css/patternfly.min.css.map
Normal file
1
awx/ui_next/public/static/css/patternfly.min.css.map
Normal file
File diff suppressed because one or more lines are too long
@ -34,7 +34,6 @@ function PageHeaderToolbar({
|
||||
const handleUserSelect = () => {
|
||||
setIsUserOpen(!isUserOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageHeaderTools>
|
||||
<PageHeaderToolsGroup>
|
||||
@ -90,8 +89,11 @@ function PageHeaderToolbar({
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
key="user"
|
||||
aria-label={i18n._(t`User details`)}
|
||||
href={
|
||||
loggedInUser ? `/users/${loggedInUser.id}/details` : '/home'
|
||||
loggedInUser
|
||||
? `/#/users/${loggedInUser.id}/details`
|
||||
: '/#/home'
|
||||
}
|
||||
>
|
||||
{i18n._(t`User Details`)}
|
||||
|
||||
@ -25,6 +25,7 @@ describe('PageHeaderToolbar', () => {
|
||||
<PageHeaderToolbar
|
||||
onAboutClick={onAboutClick}
|
||||
onLogoutClick={onLogoutClick}
|
||||
loggedInUser={{ id: 1 }}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||
@ -37,6 +38,10 @@ describe('PageHeaderToolbar', () => {
|
||||
|
||||
expect(wrapper.find('DropdownItem')).toHaveLength(0);
|
||||
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);
|
||||
|
||||
const logout = wrapper.find('DropdownItem li button');
|
||||
|
||||
@ -112,7 +112,7 @@ function AssociateModal({
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 { DetailName, DetailValue } from '../DetailList';
|
||||
import MultiButtonToggle from '../MultiButtonToggle';
|
||||
@ -111,7 +112,7 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
|
||||
css="color: var(--pf-global--danger-color--100);
|
||||
font-size: var(--pf-global--FontSize--sm"
|
||||
>
|
||||
Error: {error.message}
|
||||
<Trans>Error:</Trans> {error.message}
|
||||
</div>
|
||||
)}
|
||||
</DetailValue>
|
||||
@ -131,4 +132,4 @@ VariablesDetail.defaultProps = {
|
||||
helpText: '',
|
||||
};
|
||||
|
||||
export default VariablesDetail;
|
||||
export default withI18n()(VariablesDetail);
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import VariablesDetail from './VariablesDetail';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
describe('<VariablesDetail>', () => {
|
||||
test('should render readonly CodeMirrorInput', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = mountWithContexts(
|
||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||
);
|
||||
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
||||
@ -18,7 +18,7 @@ describe('<VariablesDetail>', () => {
|
||||
});
|
||||
|
||||
test('should detect JSON', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = mountWithContexts(
|
||||
<VariablesDetail value='{"foo": "bar"}' label="Variables" />
|
||||
);
|
||||
const input = wrapper.find('VariablesDetail___StyledCodeMirrorInput');
|
||||
@ -28,7 +28,7 @@ describe('<VariablesDetail>', () => {
|
||||
});
|
||||
|
||||
test('should convert between modes', () => {
|
||||
const wrapper = shallow(
|
||||
const wrapper = mountWithContexts(
|
||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||
);
|
||||
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
||||
@ -43,7 +43,9 @@ describe('<VariablesDetail>', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
1
|
||||
);
|
||||
@ -51,7 +53,7 @@ describe('<VariablesDetail>', () => {
|
||||
});
|
||||
|
||||
test('should update value if prop changes', () => {
|
||||
const wrapper = mount(
|
||||
const wrapper = mountWithContexts(
|
||||
<VariablesDetail value="---foo: bar" label="Variables" />
|
||||
);
|
||||
act(() => {
|
||||
@ -67,13 +69,17 @@ describe('<VariablesDetail>', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
expect(input.prop('value')).toEqual('---');
|
||||
});
|
||||
|
||||
test('should default empty json to "{}"', () => {
|
||||
const wrapper = mount(<VariablesDetail value="" label="Variables" />);
|
||||
const wrapper = mountWithContexts(
|
||||
<VariablesDetail value="" label="Variables" />
|
||||
);
|
||||
act(() => {
|
||||
wrapper.find('MultiButtonToggle').invoke('onChange')('javascript');
|
||||
});
|
||||
|
||||
@ -10,12 +10,13 @@ import AlertModal from '../AlertModal';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
|
||||
function CopyButton({
|
||||
i18n,
|
||||
id,
|
||||
copyItem,
|
||||
isDisabled,
|
||||
onCopyStart,
|
||||
onCopyFinish,
|
||||
helperText,
|
||||
i18n,
|
||||
}) {
|
||||
const { isLoading, error: copyError, request: copyItemToAPI } = useRequest(
|
||||
copyItem
|
||||
@ -34,6 +35,7 @@ function CopyButton({
|
||||
<>
|
||||
<Tooltip content={helperText.tooltip} position="top">
|
||||
<Button
|
||||
id={id}
|
||||
isDisabled={isLoading || isDisabled}
|
||||
aria-label={i18n._(t`Copy`)}
|
||||
variant="plain"
|
||||
|
||||
@ -42,7 +42,7 @@ function DeleteButton({
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
|
||||
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal file
51
awx/ui_next/src/components/DetailList/LaunchedByDetail.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { t } from '@lingui/macro';
|
||||
import Detail from './Detail';
|
||||
|
||||
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
||||
const {
|
||||
created_by: createdBy,
|
||||
job_template: jobTemplate,
|
||||
schedule,
|
||||
} = summary_fields;
|
||||
const { schedule: relatedSchedule } = related;
|
||||
|
||||
if (!createdBy && !schedule) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let link;
|
||||
let value;
|
||||
|
||||
if (createdBy) {
|
||||
link = `/users/${createdBy.id}`;
|
||||
value = createdBy.username;
|
||||
} else if (relatedSchedule && jobTemplate) {
|
||||
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
|
||||
value = schedule.name;
|
||||
} else {
|
||||
link = null;
|
||||
value = schedule.name;
|
||||
}
|
||||
|
||||
return { link, value };
|
||||
};
|
||||
|
||||
export default function LaunchedByDetail({ job, i18n }) {
|
||||
const { value: launchedByValue, link: launchedByLink } =
|
||||
getLaunchedByDetails(job) || {};
|
||||
|
||||
return (
|
||||
<Detail
|
||||
label={i18n._(t`Launched By`)}
|
||||
value={
|
||||
launchedByLink ? (
|
||||
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
||||
) : (
|
||||
launchedByValue
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -4,6 +4,7 @@ export { default as DeletedDetail } from './DeletedDetail';
|
||||
export { default as UserDateDetail } from './UserDateDetail';
|
||||
export { default as DetailBadge } from './DetailBadge';
|
||||
export { default as ArrayDetail } from './ArrayDetail';
|
||||
export { default as LaunchedByDetail } from './LaunchedByDetail';
|
||||
/*
|
||||
NOTE: CodeDetail cannot be imported here, as it causes circular
|
||||
dependencies in testing environment. Import it directly from
|
||||
|
||||
@ -118,7 +118,7 @@ function DisassociateButton({
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
|
||||
@ -20,7 +20,7 @@ const FormActionGroup = ({ onCancel, onSubmit, submitDisabled, i18n }) => {
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
|
||||
@ -7,7 +7,8 @@ import { Card } from '@patternfly/react-core';
|
||||
import AlertModal from '../AlertModal';
|
||||
import DatalistToolbar from '../DataListToolbar';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||
import useRequest, {
|
||||
useDeleteItems,
|
||||
useDismissableError,
|
||||
@ -27,7 +28,7 @@ import {
|
||||
} from '../../api';
|
||||
|
||||
function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
const QS_CONFIG = getQSConfig(
|
||||
const qsConfig = getQSConfig(
|
||||
'job',
|
||||
{
|
||||
page: 1,
|
||||
@ -49,7 +50,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
const [response, actionsResponse] = await Promise.all([
|
||||
UnifiedJobsAPI.read({ ...params }),
|
||||
UnifiedJobsAPI.readOptions(),
|
||||
@ -81,7 +82,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
// TODO: update QS_CONFIG to be safe for deps array
|
||||
const fetchJobsById = useCallback(
|
||||
async ids => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
params.id__in = ids.join(',');
|
||||
const { data } = await UnifiedJobsAPI.read(params);
|
||||
return data.results;
|
||||
@ -89,7 +90,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
[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;
|
||||
|
||||
@ -145,7 +146,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
qsConfig,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchJobs,
|
||||
}
|
||||
@ -176,14 +177,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading || isCancelLoading}
|
||||
items={jobs}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Jobs`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
qsConfig={qsConfig}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
@ -233,32 +233,18 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
key: 'job__limit',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Finish Time`),
|
||||
key: 'finished',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`ID`),
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Launched By`),
|
||||
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',
|
||||
},
|
||||
]}
|
||||
headerRow={
|
||||
<HeaderRow qsConfig={qsConfig} isExpandable>
|
||||
<HeaderCell sortKey="name">{i18n._(t`Name`)}</HeaderCell>
|
||||
<HeaderCell sortKey="status">{i18n._(t`Status`)}</HeaderCell>
|
||||
{showTypeColumn && <HeaderCell>{i18n._(t`Type`)}</HeaderCell>}
|
||||
<HeaderCell sortKey="started">{i18n._(t`Start Time`)}</HeaderCell>
|
||||
<HeaderCell sortKey="finished">
|
||||
{i18n._(t`Finish Time`)}
|
||||
</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Actions`)}</HeaderCell>
|
||||
</HeaderRow>
|
||||
}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
@ -267,13 +253,13 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
qsConfig={qsConfig}
|
||||
additionalControls={[
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleJobDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Jobs"
|
||||
pluralizedItemName={i18n._(t`Jobs`)}
|
||||
/>,
|
||||
<JobListCancelButton
|
||||
key="cancel"
|
||||
@ -283,13 +269,14 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={job => (
|
||||
renderRow={(job, index) => (
|
||||
<JobListItem
|
||||
key={job.id}
|
||||
job={job}
|
||||
showTypeColumn={showTypeColumn}
|
||||
onSelect={() => handleSelect(job)}
|
||||
isSelected={selected.some(row => row.id === job.id)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button, Chip } from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { RocketIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||
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 { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 40px;
|
||||
`;
|
||||
|
||||
const Dash = styled.span``;
|
||||
function JobListItem({
|
||||
i18n,
|
||||
job,
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showTypeColumn = false,
|
||||
}) {
|
||||
const labelId = `check-action-${job.id}`;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const jobTypes = {
|
||||
project_update: i18n._(t`Source Control Update`),
|
||||
@ -44,67 +36,123 @@ function JobListItem({
|
||||
workflow_job: i18n._(t`Workflow Job`),
|
||||
};
|
||||
|
||||
const { credentials, inventory, labels } = job.summary_fields;
|
||||
|
||||
return (
|
||||
<DataListItem aria-labelledby={labelId} id={`${job.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-job-${job.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
<>
|
||||
<Tr id={`job-row-${job.id}`}>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex: job.id,
|
||||
isExpanded,
|
||||
onToggle: () => setIsExpanded(!isExpanded),
|
||||
}}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="status" isFilled={false}>
|
||||
{job.status && <StatusIcon status={job.status} />}
|
||||
</DataListCell>,
|
||||
<DataListCell key="name">
|
||||
<span>
|
||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||
<b>
|
||||
{job.id} — {job.name}
|
||||
</b>
|
||||
</Link>
|
||||
</span>
|
||||
</DataListCell>,
|
||||
...(showTypeColumn
|
||||
? [
|
||||
<DataListCell key="type" aria-label="type">
|
||||
{jobTypes[job.type]}
|
||||
</DataListCell>,
|
||||
]
|
||||
: []),
|
||||
<DataListCell key="finished">
|
||||
{job.finished ? formatDateString(job.finished) : ''}
|
||||
</DataListCell>,
|
||||
]}
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Select`)}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
{job.type !== 'system_job' &&
|
||||
job.summary_fields?.user_capabilities?.start ? (
|
||||
<Tooltip content={i18n._(t`Relaunch Job`)} position="top">
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch }) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={handleRelaunch}
|
||||
aria-label={i18n._(t`Relaunch`)}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<span>
|
||||
<Link to={`/jobs/${JOB_TYPE_URL_SEGMENTS[job.type]}/${job.id}`}>
|
||||
<b>
|
||||
{job.id} <Dash>—</Dash> {job.name}
|
||||
</b>
|
||||
</Link>
|
||||
</span>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Status`)}>
|
||||
{job.status && <StatusLabel status={job.status} />}
|
||||
</Td>
|
||||
{showTypeColumn && (
|
||||
<Td dataLabel={i18n._(t`Type`)}>{jobTypes[job.type]}</Td>
|
||||
)}
|
||||
<Td dataLabel={i18n._(t`Start Time`)}>
|
||||
{formatDateString(job.started)}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Finish Time`)}>
|
||||
{job.finished ? formatDateString(job.finished) : ''}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={
|
||||
job.type !== 'system_job' &&
|
||||
job.summary_fields?.user_capabilities?.start
|
||||
}
|
||||
tooltip={i18n._(t`Relaunch Job`)}
|
||||
>
|
||||
<LaunchButton resource={job}>
|
||||
{({ handleRelaunch }) => (
|
||||
<Button
|
||||
variant="plain"
|
||||
onClick={handleRelaunch}
|
||||
aria-label={i18n._(t`Relaunch`)}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
<Tr isExpanded={isExpanded} id={`expanded-job-row-${job.id}`}>
|
||||
<Td colSpan={2} />
|
||||
<Td colSpan={showTypeColumn ? 5 : 4}>
|
||||
<ExpandableRowContent>
|
||||
<DetailList>
|
||||
<LaunchedByDetail job={job} i18n={i18n} />
|
||||
{credentials && credentials.length > 0 && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Credentials`)}
|
||||
value={
|
||||
<ChipGroup numChips={5} totalChips={credentials.length}>
|
||||
{credentials.map(c => (
|
||||
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{labels && labels.count > 0 && (
|
||||
<Detail
|
||||
label={i18n._(t`Labels`)}
|
||||
value={
|
||||
<ChipGroup numChips={5} totalChips={labels.results.length}>
|
||||
{labels.results.map(l => (
|
||||
<Chip key={l.id} isReadOnly>
|
||||
{l.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inventory && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
<Link
|
||||
to={
|
||||
inventory.kind === 'smart'
|
||||
? `/inventories/smart_inventory/${inventory.id}`
|
||||
: `/inventories/inventory/${inventory.id}`
|
||||
}
|
||||
>
|
||||
{inventory.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,11 @@ describe('<JobListItem />', () => {
|
||||
initialEntries: ['/jobs'],
|
||||
});
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem job={mockJob} isSelected onSelect={() => {}} />,
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem job={mockJob} isSelected onSelect={() => {}} />
|
||||
</tbody>
|
||||
</table>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
});
|
||||
@ -51,32 +55,40 @@ describe('<JobListItem />', () => {
|
||||
|
||||
test('launch button hidden from users without launch capabilities', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem
|
||||
job={{
|
||||
...mockJob,
|
||||
summary_fields: { user_capabilities: { start: false } },
|
||||
}}
|
||||
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem
|
||||
job={{
|
||||
...mockJob,
|
||||
summary_fields: { user_capabilities: { start: false } },
|
||||
}}
|
||||
detailUrl={`/jobs/playbook/${mockJob.id}`}
|
||||
onSelect={() => {}}
|
||||
isSelected={false}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').length).toBe(0);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<JobListItem
|
||||
job={mockJob}
|
||||
showTypeColumn
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<JobListItem
|
||||
job={mockJob}
|
||||
showTypeColumn
|
||||
isSelected
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('DataListCell[aria-label="type"]').length).toBe(1);
|
||||
expect(wrapper.find('Td[dataLabel="Type"]').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -84,7 +84,11 @@ function CredentialPasswordsStep({ launchConfig, i18n }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{showcredentialPasswordSsh && (
|
||||
<PasswordField
|
||||
id="launch-ssh-password"
|
||||
|
||||
@ -22,7 +22,11 @@ const FieldHeader = styled.div`
|
||||
|
||||
function OtherPromptsStep({ launchConfig, i18n }) {
|
||||
return (
|
||||
<Form>
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{launchConfig.ask_job_type_on_launch && <JobTypeField i18n={i18n} />}
|
||||
{launchConfig.ask_limit_on_launch && (
|
||||
<FormField
|
||||
|
||||
@ -33,7 +33,11 @@ function SurveyStep({ surveyConfig, i18n }) {
|
||||
float: NumberField,
|
||||
};
|
||||
return (
|
||||
<Form>
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{surveyConfig.spec.map(question => {
|
||||
const Field = fieldTypes[question.type];
|
||||
return (
|
||||
|
||||
@ -103,7 +103,7 @@ function Lookup(props) {
|
||||
<Fragment>
|
||||
<InputGroup onBlur={onBlur}>
|
||||
<Button
|
||||
aria-label="Search"
|
||||
aria-label={i18n._(t`Search`)}
|
||||
id={id}
|
||||
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
|
||||
variant={ButtonVariant.control}
|
||||
@ -138,7 +138,7 @@ function Lookup(props) {
|
||||
>
|
||||
{i18n._(t`Select`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="secondary" onClick={closeModal}>
|
||||
<Button key="cancel" variant="link" onClick={closeModal}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
|
||||
@ -63,7 +63,7 @@ function NotificationListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={`items-list-item-${notification.id}`}
|
||||
id={`items-list-item-${notification.id}`}
|
||||
columns={showApprovalsToggle ? 4 : 3}
|
||||
|
||||
@ -160,7 +160,7 @@ function ToolbarDeleteButton({
|
||||
</Button>,
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
aria-label={i18n._(t`cancel delete`)}
|
||||
onClick={toggleModal}
|
||||
>
|
||||
|
||||
@ -9,7 +9,7 @@ const ActionsGrid = styled.div`
|
||||
align-items: center;
|
||||
|
||||
${props => {
|
||||
const columns = '40px '.repeat(props.numActions || 1);
|
||||
const columns = props.gridColumns || '40px '.repeat(props.numActions || 1);
|
||||
return css`
|
||||
grid-template-columns: ${columns};
|
||||
`;
|
||||
@ -17,7 +17,7 @@ const ActionsGrid = styled.div`
|
||||
`;
|
||||
ActionsGrid.displayName = 'ActionsGrid';
|
||||
|
||||
export default function ActionsTd({ children, ...props }) {
|
||||
export default function ActionsTd({ children, gridColumns, ...props }) {
|
||||
const numActions = children.length || 1;
|
||||
const width = numActions * 40;
|
||||
return (
|
||||
@ -28,7 +28,7 @@ export default function ActionsTd({ children, ...props }) {
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<ActionsGrid numActions={numActions}>
|
||||
<ActionsGrid numActions={numActions} gridColumns={gridColumns}>
|
||||
{React.Children.map(children, (child, i) =>
|
||||
React.cloneElement(child, {
|
||||
column: i + 1,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'styled-components/macro';
|
||||
import React from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import { Thead, Tr, Th as PFTh } from '@patternfly/react-table';
|
||||
@ -12,7 +13,7 @@ const Th = styled(PFTh)`
|
||||
--pf-c-table--cell--Overflow: initial;
|
||||
`;
|
||||
|
||||
export default function HeaderRow({ qsConfig, children }) {
|
||||
export default function HeaderRow({ qsConfig, isExpandable, children }) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@ -41,25 +42,40 @@ export default function HeaderRow({ qsConfig, children }) {
|
||||
index: sortKey || qsConfig.defaultParams?.order_by,
|
||||
direction: params.order_by?.startsWith('-') ? 'desc' : 'asc',
|
||||
};
|
||||
const idPrefix = `${qsConfig.namespace}-table-sort`;
|
||||
|
||||
// empty first Th aligns with checkboxes in table rows
|
||||
return (
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isExpandable && <Th />}
|
||||
<Th />
|
||||
{React.Children.map(children, child =>
|
||||
React.cloneElement(child, {
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex: child.props.sortKey,
|
||||
})
|
||||
{React.Children.map(
|
||||
children,
|
||||
child =>
|
||||
child &&
|
||||
React.cloneElement(child, {
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex: child.props.sortKey,
|
||||
idPrefix,
|
||||
})
|
||||
)}
|
||||
</Tr>
|
||||
</Thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
||||
export function HeaderCell({
|
||||
sortKey,
|
||||
onSort,
|
||||
sortBy,
|
||||
columnIndex,
|
||||
idPrefix,
|
||||
className,
|
||||
alignRight,
|
||||
children,
|
||||
}) {
|
||||
const sort = sortKey
|
||||
? {
|
||||
onSort: (event, key, order) => onSort(sortKey, order),
|
||||
@ -67,5 +83,14 @@ export function HeaderCell({ sortKey, onSort, sortBy, columnIndex, children }) {
|
||||
columnIndex,
|
||||
}
|
||||
: null;
|
||||
return <Th sort={sort}>{children}</Th>;
|
||||
return (
|
||||
<Th
|
||||
id={sortKey ? `${idPrefix}-${sortKey}` : null}
|
||||
className={className}
|
||||
sort={sort}
|
||||
css={alignRight ? 'text-align: right' : null}
|
||||
>
|
||||
{children}
|
||||
</Th>
|
||||
);
|
||||
}
|
||||
|
||||
@ -62,4 +62,20 @@ describe('<HeaderRow />', () => {
|
||||
const cell = wrapper.find('Th').at(2);
|
||||
expect(cell.prop('sort')).toEqual(null);
|
||||
});
|
||||
|
||||
test('should handle null children gracefully', async () => {
|
||||
const nope = false;
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<HeaderRow qsConfig={qsConfig}>
|
||||
<HeaderCell sortKey="one">One</HeaderCell>
|
||||
{nope && <HeaderCell>Hidden</HeaderCell>}
|
||||
<HeaderCell>Two</HeaderCell>
|
||||
</HeaderRow>
|
||||
</table>
|
||||
);
|
||||
|
||||
const cells = wrapper.find('Th');
|
||||
expect(cells).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { TableComposable, Tbody } from '@patternfly/react-table';
|
||||
@ -88,13 +89,13 @@ function PaginatedTable({
|
||||
);
|
||||
} else {
|
||||
Content = (
|
||||
<>
|
||||
<div css="overflow: auto">
|
||||
{hasContentLoading && <LoadingSpinner />}
|
||||
<TableComposable aria-label={dataListLabel}>
|
||||
{headerRow}
|
||||
<Tbody>{items.map(renderRow)}</Tbody>
|
||||
</TableComposable>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { withI18n } from '@lingui/react';
|
||||
import { t, Trans } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 CredentialChip from '../CredentialChip';
|
||||
@ -18,9 +18,19 @@ import PromptInventorySourceDetail from './PromptInventorySourceDetail';
|
||||
import PromptJobTemplateDetail from './PromptJobTemplateDetail';
|
||||
import PromptWFJobTemplateDetail from './PromptWFJobTemplateDetail';
|
||||
|
||||
const PromptHeader = styled.h2`
|
||||
font-weight: bold;
|
||||
margin: var(--pf-global--spacer--lg) 0;
|
||||
const PromptTitle = styled(Title)`
|
||||
margin-top: var(--pf-global--spacer--xl);
|
||||
--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) {
|
||||
@ -136,9 +146,11 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
|
||||
|
||||
{hasPromptData(launchConfig) && hasOverrides && (
|
||||
<>
|
||||
<Divider css="margin-top: var(--pf-global--spacer--lg)" />
|
||||
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
|
||||
<DetailList aria-label="Prompt Overrides">
|
||||
<PromptTitle headingLevel="h2">
|
||||
{i18n._(t`Prompted Values`)}
|
||||
</PromptTitle>
|
||||
<PromptDivider />
|
||||
<PromptDetailList aria-label={i18n._(t`Prompt Overrides`)}>
|
||||
{launchConfig.ask_job_type_on_launch && (
|
||||
<Detail
|
||||
label={i18n._(t`Job Type`)}
|
||||
@ -250,7 +262,7 @@ function PromptDetail({ i18n, resource, launchConfig = {}, overrides = {} }) {
|
||||
value={overrides.extra_vars}
|
||||
/>
|
||||
)}
|
||||
</DetailList>
|
||||
</PromptDetailList>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -36,7 +36,7 @@ function DeleteRoleConfirmationModal({
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="secondary" onClick={onCancel}>
|
||||
<Button key="cancel" variant="link" onClick={onCancel}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
|
||||
@ -29,7 +29,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={[Function]}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
@ -56,7 +56,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={[Function]}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
@ -80,7 +80,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={[Function]}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
@ -212,8 +212,8 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
class="pf-c-button pf-m-secondary"
|
||||
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
||||
class="pf-c-button pf-m-link"
|
||||
data-ouia-component-id="OUIA-Generated-Button-link-1"
|
||||
data-ouia-component-type="PF4/Button"
|
||||
data-ouia-safe="true"
|
||||
type="button"
|
||||
@ -239,7 +239,7 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
</Button>,
|
||||
<Button
|
||||
onClick={[Function]}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
@ -517,13 +517,13 @@ exports[`<DeleteRoleConfirmationModal /> should render initially 1`] = `
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={[Function]}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
>
|
||||
<button
|
||||
aria-disabled={false}
|
||||
aria-label={null}
|
||||
className="pf-c-button pf-m-secondary"
|
||||
data-ouia-component-id="OUIA-Generated-Button-secondary-1"
|
||||
className="pf-c-button pf-m-link"
|
||||
data-ouia-component-id="OUIA-Generated-Button-link-1"
|
||||
data-ouia-component-type="PF4/Button"
|
||||
data-ouia-safe={true}
|
||||
disabled={false}
|
||||
|
||||
@ -5,7 +5,7 @@ import { RRule, rrulestr } from 'rrule';
|
||||
import styled from 'styled-components';
|
||||
import { withI18n } from '@lingui/react';
|
||||
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 AlertModal from '../../AlertModal';
|
||||
import { CardBody, CardActionsRow } from '../../Card';
|
||||
@ -27,11 +27,21 @@ import ErrorDetail from '../../ErrorDetail';
|
||||
import ChipGroup from '../../ChipGroup';
|
||||
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)`
|
||||
margin-top: 40px;
|
||||
--pf-c-title--m-md--FontWeight: 700;
|
||||
grid-column: 1 / -1;
|
||||
`;
|
||||
|
||||
const PromptDetailList = styled(DetailList)`
|
||||
padding: 0px 20px;
|
||||
`;
|
||||
|
||||
function ScheduleDetail({ schedule, i18n }) {
|
||||
const {
|
||||
id,
|
||||
@ -41,6 +51,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
dtend,
|
||||
dtstart,
|
||||
extra_data,
|
||||
inventory,
|
||||
job_tags,
|
||||
job_type,
|
||||
limit,
|
||||
@ -52,12 +63,21 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
skip_tags,
|
||||
summary_fields,
|
||||
timezone,
|
||||
verbosity,
|
||||
} = schedule;
|
||||
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
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 {
|
||||
request: deleteSchedule,
|
||||
isLoading: isDeleteLoading,
|
||||
@ -140,18 +160,34 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
survey_enabled,
|
||||
} = 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 =
|
||||
ask_credential_on_launch ||
|
||||
ask_diff_mode_on_launch ||
|
||||
ask_inventory_on_launch ||
|
||||
ask_job_type_on_launch ||
|
||||
ask_limit_on_launch ||
|
||||
ask_scm_branch_on_launch ||
|
||||
ask_skip_tags_on_launch ||
|
||||
ask_tags_on_launch ||
|
||||
ask_variables_on_launch ||
|
||||
ask_verbosity_on_launch ||
|
||||
survey_enabled;
|
||||
showCredentialsDetail ||
|
||||
showDiffModeDetail ||
|
||||
showInventoryDetail ||
|
||||
showJobTypeDetail ||
|
||||
showLimitDetail ||
|
||||
showSCMBranchDetail ||
|
||||
showSkipTagsDetail ||
|
||||
showTagsDetail ||
|
||||
showVerbosityDetail ||
|
||||
showVariablesDetail;
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
@ -189,15 +225,18 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
date={modified}
|
||||
user={summary_fields.modified_by}
|
||||
/>
|
||||
{showPromptedFields && (
|
||||
<>
|
||||
<PromptTitle headingLevel="h2">
|
||||
{i18n._(t`Prompted Fields`)}
|
||||
</PromptTitle>
|
||||
</DetailList>
|
||||
{showPromptedFields && (
|
||||
<>
|
||||
<PromptTitle headingLevel="h2">
|
||||
{i18n._(t`Prompted Values`)}
|
||||
</PromptTitle>
|
||||
<PromptDivider />
|
||||
<PromptDetailList>
|
||||
{ask_job_type_on_launch && (
|
||||
<Detail label={i18n._(t`Job Type`)} value={job_type} />
|
||||
)}
|
||||
{ask_inventory_on_launch && (
|
||||
{showInventoryDetail && (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={
|
||||
@ -226,13 +265,19 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
{ask_limit_on_launch && (
|
||||
<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
|
||||
label={i18n._(t`Show Changes`)}
|
||||
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
|
||||
/>
|
||||
)}
|
||||
{ask_credential_on_launch && (
|
||||
{showCredentialsDetail && (
|
||||
<Detail
|
||||
fullWidth
|
||||
label={i18n._(t`Credentials`)}
|
||||
@ -245,7 +290,7 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ask_tags_on_launch && job_tags && job_tags.length > 0 && (
|
||||
{showTagsDetail && (
|
||||
<Detail
|
||||
fullWidth
|
||||
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
|
||||
fullWidth
|
||||
label={i18n._(t`Skip Tags`)}
|
||||
@ -281,16 +326,16 @@ function ScheduleDetail({ schedule, i18n }) {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(ask_variables_on_launch || survey_enabled) && (
|
||||
{showVariablesDetail && (
|
||||
<VariablesDetail
|
||||
value={extra_data}
|
||||
rows={4}
|
||||
label={i18n._(t`Variables`)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailList>
|
||||
</PromptDetailList>
|
||||
</>
|
||||
)}
|
||||
<CardActionsRow>
|
||||
{summary_fields?.user_capabilities?.edit && (
|
||||
<Button
|
||||
|
||||
@ -73,10 +73,6 @@ const schedule = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
},
|
||||
inventory: {
|
||||
id: 1,
|
||||
name: 'Test Inventory',
|
||||
},
|
||||
},
|
||||
created: '2020-03-03T20:38:54.210306Z',
|
||||
modified: '2020-03-03T20:38:54.210336Z',
|
||||
@ -88,6 +84,27 @@ const schedule = {
|
||||
dtend: '2020-07-06T04:00:00Z',
|
||||
next_run: '2020-03-16T04:00:00Z',
|
||||
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({
|
||||
@ -159,13 +176,14 @@ describe('<ScheduleDetail />', () => {
|
||||
expect(wrapper.find('Detail[label="Repeat Frequency"]').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('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="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);
|
||||
@ -189,18 +207,6 @@ describe('<ScheduleDetail />', () => {
|
||||
},
|
||||
});
|
||||
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 () => {
|
||||
wrapper = mountWithContexts(
|
||||
<Route
|
||||
@ -245,7 +251,7 @@ describe('<ScheduleDetail />', () => {
|
||||
expect(wrapper.find('Detail[label="Repeat Frequency"]').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('Title[children="Prompted Fields"]').length).toBe(1);
|
||||
expect(wrapper.find('Title[children="Prompted Values"]').length).toBe(1);
|
||||
expect(
|
||||
wrapper
|
||||
.find('Detail[label="Job Type"]')
|
||||
@ -265,12 +271,102 @@ describe('<ScheduleDetail />', () => {
|
||||
.find('dd')
|
||||
.text()
|
||||
).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="Credentials"]').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('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 () => {
|
||||
SchedulesAPI.readCredentials.mockRejectedValueOnce(
|
||||
new Error({
|
||||
|
||||
@ -6,11 +6,9 @@ import { t } from '@lingui/macro';
|
||||
import { SchedulesAPI } from '../../../api';
|
||||
import AlertModal from '../../AlertModal';
|
||||
import ErrorDetail from '../../ErrorDetail';
|
||||
import PaginatedTable, { HeaderRow, HeaderCell } from '../../PaginatedTable';
|
||||
import DataListToolbar from '../../DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../PaginatedDataList';
|
||||
import { ToolbarAddButton, ToolbarDeleteButton } from '../../PaginatedDataList';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import ScheduleListItem from './ScheduleListItem';
|
||||
@ -119,19 +117,28 @@ function ScheduleList({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={schedules}
|
||||
itemCount={itemCount}
|
||||
qsConfig={QS_CONFIG}
|
||||
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
|
||||
isSelected={selected.some(row => row.id === item.id)}
|
||||
key={item.id}
|
||||
onSelect={() => handleSelect(item)}
|
||||
schedule={item}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
toolbarSearchColumns={[
|
||||
@ -153,16 +160,6 @@ function ScheduleList({
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Next Run`),
|
||||
key: 'next_run',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
|
||||
@ -59,44 +59,61 @@ describe('ScheduleList', () => {
|
||||
|
||||
test('should check and uncheck the row item', async () => {
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-schedule-1"]')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(true);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-schedule-1"]')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-schedule-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
wrapper.find('.pf-c-table__check input').forEach(el => {
|
||||
expect(el.props().checked).toBe(true);
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('Checkbox#select-all').invoke('onChange')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
wrapper.find('DataListCheck').forEach(el => {
|
||||
wrapper.find('.pf-c-table__check input').forEach(el => {
|
||||
expect(el.props().checked).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -104,7 +121,8 @@ describe('ScheduleList', () => {
|
||||
test('should call api delete schedules for each selected schedule', async () => {
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-schedule-3"]')
|
||||
.find('.pf-c-table__check input')
|
||||
.at(3)
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
@ -122,7 +140,8 @@ describe('ScheduleList', () => {
|
||||
expect(wrapper.find('Modal').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-schedule-2"]')
|
||||
.find('.pf-c-table__check input')
|
||||
.at(2)
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
@ -4,31 +4,16 @@ import { bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../../DataListCell';
|
||||
import { DetailList, Detail } from '../../DetailList';
|
||||
import { ActionsTd, ActionItem } from '../../PaginatedTable';
|
||||
import { ScheduleToggle } from '..';
|
||||
import { Schedule } from '../../../types';
|
||||
import { formatDateString } from '../../../util/dates';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 92px 40px;
|
||||
`;
|
||||
|
||||
function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
|
||||
function ScheduleListItem({ i18n, isSelected, onSelect, schedule, rowIndex }) {
|
||||
const labelId = `check-action-${schedule.id}`;
|
||||
|
||||
const jobTypeLabels = {
|
||||
@ -62,69 +47,56 @@ function ScheduleListItem({ i18n, isSelected, onSelect, schedule }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={schedule.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${schedule.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-schedule-${schedule.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<Link to={`${scheduleBaseUrl}/details`}>
|
||||
<b>{schedule.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
{
|
||||
jobTypeLabels[
|
||||
schedule.summary_fields.unified_job_template.unified_job_type
|
||||
]
|
||||
}
|
||||
</DataListCell>,
|
||||
<DataListCell key="next_run">
|
||||
{schedule.next_run && (
|
||||
<DetailList stacked>
|
||||
<Detail
|
||||
label={i18n._(t`Next Run`)}
|
||||
value={formatDateString(schedule.next_run)}
|
||||
/>
|
||||
</DetailList>
|
||||
)}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
key="actions"
|
||||
<Tr id={`schedule-row-${schedule.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disable: false,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${scheduleBaseUrl}/details`}>
|
||||
<b>{schedule.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{
|
||||
jobTypeLabels[
|
||||
schedule.summary_fields.unified_job_template.unified_job_type
|
||||
]
|
||||
}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Next Run`)}>
|
||||
{schedule.next_run && (
|
||||
<DetailList stacked>
|
||||
<Detail
|
||||
label={i18n._(t`Next Run`)}
|
||||
value={formatDateString(schedule.next_run)}
|
||||
/>
|
||||
</DetailList>
|
||||
)}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
|
||||
<ScheduleToggle schedule={schedule} />
|
||||
<ActionItem
|
||||
visible={schedule.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit Schedule`)}
|
||||
>
|
||||
<ScheduleToggle schedule={schedule} />
|
||||
{schedule.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Schedule`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Schedule`)}
|
||||
css="grid-column: 2"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${scheduleBaseUrl}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Schedule`)}
|
||||
css="grid-column: 2"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`${scheduleBaseUrl}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -50,39 +50,47 @@ describe('ScheduleListItem', () => {
|
||||
describe('User has edit permissions', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={mockSchedule}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={mockSchedule}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Name correctly shown with correct link', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.first()
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Mock Schedule');
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.first()
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.find('Link')
|
||||
.props().to
|
||||
).toBe('/templates/job_template/12/schedules/6/details');
|
||||
});
|
||||
|
||||
test('Type correctly shown', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.at(1)
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Playbook Run');
|
||||
});
|
||||
|
||||
test('Edit button shown with correct link', () => {
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(1);
|
||||
expect(
|
||||
@ -92,6 +100,7 @@ describe('ScheduleListItem', () => {
|
||||
.props().to
|
||||
).toBe('/templates/job_template/12/schedules/6/edit');
|
||||
});
|
||||
|
||||
test('Toggle button enabled', () => {
|
||||
expect(
|
||||
wrapper
|
||||
@ -100,63 +109,74 @@ describe('ScheduleListItem', () => {
|
||||
.props().isDisabled
|
||||
).toBe(false);
|
||||
});
|
||||
test('Clicking checkbox makes expected callback', () => {
|
||||
|
||||
test('Clicking checkbox selects item', () => {
|
||||
wrapper
|
||||
.find('DataListCheck')
|
||||
.find('Td')
|
||||
.first()
|
||||
.find('input')
|
||||
.simulate('change');
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('User has read-only permissions', () => {
|
||||
beforeAll(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={{
|
||||
...mockSchedule,
|
||||
summary_fields: {
|
||||
...mockSchedule.summary_fields,
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<ScheduleListItem
|
||||
isSelected={false}
|
||||
onSelect={onSelect}
|
||||
schedule={{
|
||||
...mockSchedule,
|
||||
summary_fields: {
|
||||
...mockSchedule.summary_fields,
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('Name correctly shown with correct link', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.first()
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.text()
|
||||
).toBe('Mock Schedule');
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.first()
|
||||
.find('Td')
|
||||
.at(1)
|
||||
.find('Link')
|
||||
.props().to
|
||||
).toBe('/templates/job_template/12/schedules/6/details');
|
||||
});
|
||||
|
||||
test('Type correctly shown', () => {
|
||||
expect(
|
||||
wrapper
|
||||
.find('DataListCell')
|
||||
.at(1)
|
||||
.find('Td')
|
||||
.at(2)
|
||||
.text()
|
||||
).toBe('Playbook Run');
|
||||
});
|
||||
|
||||
test('Edit button hidden', () => {
|
||||
expect(wrapper.find('PencilAltIcon').length).toBe(0);
|
||||
});
|
||||
|
||||
test('Toggle button disabled', () => {
|
||||
expect(
|
||||
wrapper
|
||||
|
||||
@ -7,29 +7,33 @@ 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';
|
||||
} from '../../api';
|
||||
import AlertModal from '../AlertModal';
|
||||
import DatalistToolbar from '../DataListToolbar';
|
||||
import ErrorDetail from '../ErrorDetail';
|
||||
import { ToolbarDeleteButton } from '../PaginatedDataList';
|
||||
import PaginatedTable, { HeaderRow, HeaderCell } from '../PaginatedTable';
|
||||
import useRequest, { useDeleteItems } from '../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../util/qs';
|
||||
import useWsTemplates from '../../util/useWsTemplates';
|
||||
import AddDropDownButton from '../AddDropDownButton';
|
||||
import TemplateListItem from './TemplateListItem';
|
||||
|
||||
// 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 QS_CONFIG = getQSConfig('template', {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
order_by: 'name',
|
||||
type: 'job_template,workflow_job_template',
|
||||
});
|
||||
function TemplateList({ defaultParams, i18n }) {
|
||||
// The type value in const qsConfig 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 qsConfig = getQSConfig(
|
||||
'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 [selected, setSelected] = useState([]);
|
||||
|
||||
@ -47,7 +51,7 @@ function TemplateList({ i18n }) {
|
||||
request: fetchTemplates,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const params = parseQueryString(qsConfig, location.search);
|
||||
const responses = await Promise.all([
|
||||
UnifiedJobTemplatesAPI.read(params),
|
||||
JobTemplatesAPI.readOptions(),
|
||||
@ -66,7 +70,7 @@ function TemplateList({ i18n }) {
|
||||
responses[3].data.actions?.GET || {}
|
||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
}, [location]), // eslint-disable-line react-hooks/exhaustive-deps
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
@ -105,7 +109,7 @@ function TemplateList({ i18n }) {
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
qsConfig,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchTemplates,
|
||||
}
|
||||
@ -167,13 +171,13 @@ function TemplateList({ i18n }) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={templates}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Templates`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
qsConfig={qsConfig}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
@ -206,53 +210,37 @@ function TemplateList({ i18n }) {
|
||||
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}
|
||||
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 => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
qsConfig={qsConfig}
|
||||
additionalControls={[
|
||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleTemplateDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Templates"
|
||||
pluralizedItemName={i18n._(t`Templates`)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={template => (
|
||||
renderRow={(template, index) => (
|
||||
<TemplateListItem
|
||||
key={template.id}
|
||||
value={template.name}
|
||||
@ -261,6 +249,7 @@ function TemplateList({ i18n }) {
|
||||
onSelect={() => handleSelect(template)}
|
||||
isSelected={selected.some(row => row.id === template.id)}
|
||||
fetchTemplates={fetchTemplates}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||
@ -4,15 +4,15 @@ import {
|
||||
JobTemplatesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
} from '../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
} from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import TemplateList from './TemplateList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
jest.mock('../../api');
|
||||
|
||||
const mockTemplates = [
|
||||
{
|
||||
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
278
awx/ui_next/src/components/TemplateList/TemplateListItem.jsx
Normal file
@ -0,0 +1,278 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Tooltip, Chip } from '@patternfly/react-core';
|
||||
import { Tr, Td, ExpandableRowContent } from '@patternfly/react-table';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
PencilAltIcon,
|
||||
ProjectDiagramIcon,
|
||||
RocketIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import { ActionsTd, ActionItem } from '../PaginatedTable';
|
||||
import { DetailList, Detail, DeletedDetail } from '../DetailList';
|
||||
import ChipGroup from '../ChipGroup';
|
||||
import CredentialChip from '../CredentialChip';
|
||||
import { timeOfDay, formatDateString } from '../../util/dates';
|
||||
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api';
|
||||
import LaunchButton from '../LaunchButton';
|
||||
import Sparkline from '../Sparkline';
|
||||
import { toTitleCase } from '../../util/strings';
|
||||
import CopyButton from '../CopyButton';
|
||||
|
||||
function TemplateListItem({
|
||||
i18n,
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
fetchTemplates,
|
||||
rowIndex,
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
const labelId = `check-action-${template.id}`;
|
||||
|
||||
const copyTemplate = useCallback(async () => {
|
||||
if (template.type === 'job_template') {
|
||||
await JobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
} else {
|
||||
await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
}
|
||||
await fetchTemplates();
|
||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsDisabled(true);
|
||||
}, []);
|
||||
|
||||
const handleCopyFinish = useCallback(() => {
|
||||
setIsDisabled(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
summary_fields: summaryFields,
|
||||
ask_inventory_on_launch: askInventoryOnLaunch,
|
||||
} = template;
|
||||
|
||||
const missingResourceIcon =
|
||||
template.type === 'job_template' &&
|
||||
(!summaryFields.project ||
|
||||
(!summaryFields.inventory && !askInventoryOnLaunch));
|
||||
|
||||
const inventoryValue = (kind, id) => {
|
||||
const inventorykind = kind === 'smart' ? 'smart_inventory' : 'inventory';
|
||||
|
||||
return askInventoryOnLaunch ? (
|
||||
<>
|
||||
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||
{summaryFields.inventory.name}
|
||||
</Link>
|
||||
<span> {i18n._(t`(Prompt on launch)`)}</span>
|
||||
</>
|
||||
) : (
|
||||
<Link to={`/inventories/${inventorykind}/${id}/details`}>
|
||||
{summaryFields.inventory.name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
let lastRun = '';
|
||||
const mostRecentJob = template.summary_fields.recent_jobs
|
||||
? template.summary_fields.recent_jobs[0]
|
||||
: null;
|
||||
if (mostRecentJob) {
|
||||
lastRun = mostRecentJob.finished
|
||||
? formatDateString(mostRecentJob.finished)
|
||||
: i18n._(t`Running`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr id={`template-row-${template.id}`}>
|
||||
<Td
|
||||
expand={{
|
||||
rowIndex,
|
||||
isExpanded,
|
||||
onToggle: () => setIsExpanded(!isExpanded),
|
||||
}}
|
||||
/>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
{missingResourceIcon && (
|
||||
<span>
|
||||
<Tooltip
|
||||
content={i18n._(t`Resources are missing from this template.`)}
|
||||
position="right"
|
||||
>
|
||||
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>{toTitleCase(template.type)}</Td>
|
||||
<Td dataLabel={i18n._(t`Last Ran`)}>{lastRun}</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem
|
||||
visible={template.type === 'workflow_job_template'}
|
||||
tooltip={i18n._(t`Visualizer`)}
|
||||
>
|
||||
<Button
|
||||
id={`template-action-visualizer-${template.id}`}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Visualizer`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||
>
|
||||
<ProjectDiagramIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.start}
|
||||
tooltip={i18n._(t`Launch Template`)}
|
||||
>
|
||||
<LaunchButton resource={template}>
|
||||
{({ handleLaunch }) => (
|
||||
<Button
|
||||
id={`template-action-launch-${template.id}`}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Launch template`)}
|
||||
variant="plain"
|
||||
onClick={handleLaunch}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</ActionItem>
|
||||
<ActionItem
|
||||
visible={template.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit Template`)}
|
||||
>
|
||||
<Button
|
||||
id={`template-action-edit-${template.id}`}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Edit Template`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/${template.type}/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem visible={template.summary_fields.user_capabilities.copy}>
|
||||
<CopyButton
|
||||
id={`template-action-copy-${template.id}`}
|
||||
helperText={{
|
||||
errorMessage: i18n._(t`Failed to copy template.`),
|
||||
tooltip: i18n._(t`Copy Template`),
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
copyItem={copyTemplate}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
<Tr isExpanded={isExpanded}>
|
||||
<Td colSpan={2} />
|
||||
<Td colSpan={4}>
|
||||
<ExpandableRowContent>
|
||||
<DetailList>
|
||||
<Detail
|
||||
label={i18n._(t`Activity`)}
|
||||
value={<Sparkline jobs={summaryFields.recent_jobs} />}
|
||||
dataCy={`template-${template.id}-activity`}
|
||||
/>
|
||||
{summaryFields.credentials && summaryFields.credentials.length && (
|
||||
<Detail
|
||||
label={i18n._(t`Credentials`)}
|
||||
value={
|
||||
<ChipGroup
|
||||
numChips={5}
|
||||
totalChips={summaryFields.credentials.length}
|
||||
>
|
||||
{summaryFields.credentials.map(c => (
|
||||
<CredentialChip key={c.id} credential={c} isReadOnly />
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
dataCy={`template-${template.id}-credentials`}
|
||||
/>
|
||||
)}
|
||||
{summaryFields.inventory ? (
|
||||
<Detail
|
||||
label={i18n._(t`Inventory`)}
|
||||
value={inventoryValue(
|
||||
summaryFields.inventory.kind,
|
||||
summaryFields.inventory.id
|
||||
)}
|
||||
dataCy={`template-${template.id}-inventory`}
|
||||
/>
|
||||
) : (
|
||||
!askInventoryOnLaunch && (
|
||||
<DeletedDetail label={i18n._(t`Inventory`)} />
|
||||
)
|
||||
)}
|
||||
{summaryFields.labels && summaryFields.labels.results.length > 0 && (
|
||||
<Detail
|
||||
label={i18n._(t`Labels`)}
|
||||
value={
|
||||
<ChipGroup
|
||||
numChips={5}
|
||||
totalChips={summaryFields.labels.results.length}
|
||||
>
|
||||
{summaryFields.labels.results.map(l => (
|
||||
<Chip key={l.id} isReadOnly>
|
||||
{l.name}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
}
|
||||
dataCy={`template-${template.id}-labels`}
|
||||
/>
|
||||
)}
|
||||
{summaryFields.project && (
|
||||
<Detail
|
||||
label={i18n._(t`Project`)}
|
||||
value={
|
||||
<Link to={`/projects/${summaryFields.project.id}/details`}>
|
||||
{summaryFields.project.name}
|
||||
</Link>
|
||||
}
|
||||
dataCy={`template-${template.id}-project`}
|
||||
/>
|
||||
)}
|
||||
<Detail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
value={formatDateString(template.modified)}
|
||||
dataCy={`template-${template.id}-last-modified`}
|
||||
/>
|
||||
</DetailList>
|
||||
</ExpandableRowContent>
|
||||
</Td>
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { TemplateListItem as _TemplateListItem };
|
||||
export default withI18n()(TemplateListItem);
|
||||
@ -0,0 +1,323 @@
|
||||
import React from 'react';
|
||||
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
import { JobTemplatesAPI } from '../../api';
|
||||
import mockJobTemplateData from './data.job_template.json';
|
||||
import TemplateListItem from './TemplateListItem';
|
||||
|
||||
jest.mock('../../api');
|
||||
|
||||
describe('<TemplateListItem />', () => {
|
||||
test('launch button shown to users with start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
|
||||
});
|
||||
test('launch button hidden from users without start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
|
||||
});
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
test('missing resource icon is shown.', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
|
||||
});
|
||||
test('missing resource icon is not shown when there is a project and an inventory.', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
project: { name: 'Foo', id: 2 },
|
||||
inventory: { name: 'Bar', id: 2 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
ask_inventory_on_launch: true,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
project: { name: 'Foo', id: 2 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('missing resource icon is not shown type is workflow_job_template', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('clicking on template from templates list navigates properly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/templates'],
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
wrapper.find('Link').simulate('click', { button: 0 });
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/templates/job_template/1/details'
|
||||
);
|
||||
});
|
||||
test('should call api to copy template', async () => {
|
||||
JobTemplatesAPI.copy.mockResolvedValue();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render proper alert modal on copy error', async () => {
|
||||
JobTemplatesAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
summary_fields: { user_capabilities: { copy: false } },
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should render visualizer button for workflow', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
type: 'workflow_job_template',
|
||||
}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should not render visualizer button for job template', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<table>
|
||||
<tbody>
|
||||
<TemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||
});
|
||||
});
|
||||
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
181
awx/ui_next/src/components/TemplateList/data.job_template.json
Normal file
@ -0,0 +1,181 @@
|
||||
{
|
||||
"id": 7,
|
||||
"type": "job_template",
|
||||
"url": "/api/v2/job_templates/7/",
|
||||
"related": {
|
||||
"named_url": "/api/v2/job_templates/Mike's JT/",
|
||||
"created_by": "/api/v2/users/1/",
|
||||
"modified_by": "/api/v2/users/1/",
|
||||
"labels": "/api/v2/job_templates/7/labels/",
|
||||
"inventory": "/api/v2/inventories/1/",
|
||||
"project": "/api/v2/projects/6/",
|
||||
"credentials": "/api/v2/job_templates/7/credentials/",
|
||||
"last_job": "/api/v2/jobs/12/",
|
||||
"jobs": "/api/v2/job_templates/7/jobs/",
|
||||
"schedules": "/api/v2/job_templates/7/schedules/",
|
||||
"activity_stream": "/api/v2/job_templates/7/activity_stream/",
|
||||
"launch": "/api/v2/job_templates/7/launch/",
|
||||
"notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/",
|
||||
"notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/",
|
||||
"notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/",
|
||||
"access_list": "/api/v2/job_templates/7/access_list/",
|
||||
"survey_spec": "/api/v2/job_templates/7/survey_spec/",
|
||||
"object_roles": "/api/v2/job_templates/7/object_roles/",
|
||||
"instance_groups": "/api/v2/job_templates/7/instance_groups/",
|
||||
"slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/",
|
||||
"copy": "/api/v2/job_templates/7/copy/",
|
||||
"webhook_receiver": "/api/v2/job_templates/7/github/",
|
||||
"webhook_key": "/api/v2/job_templates/7/webhook_key/"
|
||||
},
|
||||
"summary_fields": {
|
||||
"inventory": {
|
||||
"id": 1,
|
||||
"name": "Mike's Inventory",
|
||||
"description": "",
|
||||
"has_active_failures": false,
|
||||
"total_hosts": 1,
|
||||
"hosts_with_active_failures": 0,
|
||||
"total_groups": 0,
|
||||
"groups_with_active_failures": 0,
|
||||
"has_inventory_sources": false,
|
||||
"total_inventory_sources": 0,
|
||||
"inventory_sources_with_failures": 0,
|
||||
"organization_id": 1,
|
||||
"kind": ""
|
||||
},
|
||||
"project": {
|
||||
"id": 6,
|
||||
"name": "Mike's Project",
|
||||
"description": "",
|
||||
"status": "successful",
|
||||
"scm_type": "git"
|
||||
},
|
||||
"last_job": {
|
||||
"id": 12,
|
||||
"name": "Mike's JT",
|
||||
"description": "",
|
||||
"finished": "2019-10-01T14:34:35.142483Z",
|
||||
"status": "successful",
|
||||
"failed": false
|
||||
},
|
||||
"last_update": {
|
||||
"id": 12,
|
||||
"name": "Mike's JT",
|
||||
"description": "",
|
||||
"status": "successful",
|
||||
"failed": false
|
||||
},
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"modified_by": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"first_name": "",
|
||||
"last_name": ""
|
||||
},
|
||||
"object_roles": {
|
||||
"admin_role": {
|
||||
"description": "Can manage all aspects of the job template",
|
||||
"name": "Admin",
|
||||
"id": 24
|
||||
},
|
||||
"execute_role": {
|
||||
"description": "May run the job template",
|
||||
"name": "Execute",
|
||||
"id": 25
|
||||
},
|
||||
"read_role": {
|
||||
"description": "May view settings for the job template",
|
||||
"name": "Read",
|
||||
"id": 26
|
||||
}
|
||||
},
|
||||
"user_capabilities": {
|
||||
"edit": true,
|
||||
"delete": true,
|
||||
"start": true,
|
||||
"schedule": true,
|
||||
"copy": true
|
||||
},
|
||||
"labels": {
|
||||
"count": 1,
|
||||
"results": [{
|
||||
"id": 91,
|
||||
"name": "L_91o2"
|
||||
}]
|
||||
},
|
||||
"survey": {
|
||||
"title": "",
|
||||
"description": ""
|
||||
},
|
||||
"recent_jobs": [{
|
||||
"id": 12,
|
||||
"status": "successful",
|
||||
"finished": "2019-10-01T14:34:35.142483Z",
|
||||
"type": "job"
|
||||
}],
|
||||
"credentials": [{
|
||||
"id": 1,
|
||||
"kind": "ssh",
|
||||
"name": "Credential 1"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"kind": "awx",
|
||||
"name": "Credential 2"
|
||||
}
|
||||
],
|
||||
"webhook_credential": {
|
||||
"id": "1",
|
||||
"name": "Webhook Credential"
|
||||
|
||||
}
|
||||
},
|
||||
"created": "2019-09-30T16:18:34.564820Z",
|
||||
"modified": "2019-10-01T14:47:31.818431Z",
|
||||
"name": "Mike's JT",
|
||||
"description": "",
|
||||
"job_type": "run",
|
||||
"inventory": 1,
|
||||
"project": 6,
|
||||
"playbook": "ping.yml",
|
||||
"scm_branch": "Foo branch",
|
||||
"forks": 0,
|
||||
"limit": "",
|
||||
"verbosity": 0,
|
||||
"extra_vars": "",
|
||||
"job_tags": "T_100,T_200",
|
||||
"force_handlers": false,
|
||||
"skip_tags": "S_100,S_200",
|
||||
"start_at_task": "",
|
||||
"timeout": 0,
|
||||
"use_fact_cache": true,
|
||||
"last_job_run": "2019-10-01T14:34:35.142483Z",
|
||||
"last_job_failed": false,
|
||||
"next_job_run": null,
|
||||
"status": "successful",
|
||||
"host_config_key": "",
|
||||
"ask_scm_branch_on_launch": false,
|
||||
"ask_diff_mode_on_launch": false,
|
||||
"ask_variables_on_launch": false,
|
||||
"ask_limit_on_launch": false,
|
||||
"ask_tags_on_launch": false,
|
||||
"ask_skip_tags_on_launch": false,
|
||||
"ask_job_type_on_launch": false,
|
||||
"ask_verbosity_on_launch": false,
|
||||
"ask_inventory_on_launch": false,
|
||||
"ask_credential_on_launch": false,
|
||||
"survey_enabled": true,
|
||||
"become_enabled": false,
|
||||
"diff_mode": false,
|
||||
"allow_simultaneous": false,
|
||||
"custom_virtualenv": null,
|
||||
"job_slice_count": 1,
|
||||
"webhook_credential": 1,
|
||||
"webhook_key": "asertdyuhjkhgfd234567kjgfds",
|
||||
"webhook_service": "github"
|
||||
}
|
||||
@ -1,2 +1,2 @@
|
||||
export { default as TemplateList } from './TemplateList';
|
||||
export { default } from './TemplateList';
|
||||
export { default as TemplateListItem } from './TemplateListItem';
|
||||
@ -78,7 +78,7 @@ function ApplicationListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -7,10 +7,14 @@ import { CredentialsAPI } from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import PaginatedDataList, {
|
||||
import {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import CredentialListItem from './CredentialListItem';
|
||||
@ -114,7 +118,7 @@ function CredentialList({ i18n }) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={credentials}
|
||||
@ -142,7 +146,14 @@ function CredentialList({ i18n }) {
|
||||
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
|
||||
key={item.id}
|
||||
credential={item}
|
||||
@ -150,6 +161,7 @@ function CredentialList({ i18n }) {
|
||||
detailUrl={`/credentials/${item.id}/details`}
|
||||
isSelected={selected.some(row => row.id === item.id)}
|
||||
onSelect={() => handleSelect(item)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => (
|
||||
|
||||
@ -57,25 +57,41 @@ describe('<CredentialList />', () => {
|
||||
|
||||
test('should check and uncheck the row item', async () => {
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(false);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-credential-1"]')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(true);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-credential-1"]')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')(false);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(
|
||||
wrapper.find('DataListCheck[id="select-credential-1"]').props().checked
|
||||
wrapper
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.props().checked
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@ -105,7 +121,9 @@ describe('<CredentialList />', () => {
|
||||
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-credential-3"]')
|
||||
.find('.pf-c-table__check')
|
||||
.at(2)
|
||||
.find('input')
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
@ -122,7 +140,9 @@ describe('<CredentialList />', () => {
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper
|
||||
.find('DataListCheck[id="select-credential-2"]')
|
||||
.find('.pf-c-table__check')
|
||||
.at(1)
|
||||
.find('input')
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
@ -3,31 +3,16 @@ import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { timeOfDay } from '../../../util/dates';
|
||||
|
||||
import { Credential } from '../../../types';
|
||||
import { CredentialsAPI } from '../../../api';
|
||||
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({
|
||||
credential,
|
||||
detailUrl,
|
||||
@ -35,6 +20,7 @@ function CredentialListItem({
|
||||
onSelect,
|
||||
i18n,
|
||||
fetchCredentials,
|
||||
rowIndex,
|
||||
}) {
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
@ -57,64 +43,49 @@ function CredentialListItem({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DataListItem
|
||||
key={credential.id}
|
||||
aria-labelledby={labelId}
|
||||
id={`${credential.id}`}
|
||||
>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
isDisabled={isDisabled}
|
||||
id={`select-credential-${credential.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{credential.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
{credential.summary_fields.credential_type.name}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
{canEdit && (
|
||||
<Tooltip content={i18n._(t`Edit Credential`)} position="top">
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Edit Credential`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/credentials/${credential.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{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>
|
||||
<Tr id={`${credential.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{credential.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Type`)}>
|
||||
{credential.summary_fields.credential_type.name}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)}>
|
||||
<ActionItem visible={canEdit} tooltip={i18n._(t`Edit Credential`)}>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Edit Credential`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/credentials/${credential.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
<ActionItem visible={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.`),
|
||||
}}
|
||||
/>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,24 +16,32 @@ describe('<CredentialListItem />', () => {
|
||||
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialListItem
|
||||
credential={mockCredentials.results[0]}
|
||||
detailUrl="/foo/bar"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialListItem
|
||||
credential={mockCredentials.results[0]}
|
||||
detailUrl="/foo/bar"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialListItem
|
||||
credential={mockCredentials.results[1]}
|
||||
detailUrl="/foo/bar"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialListItem
|
||||
credential={mockCredentials.results[1]}
|
||||
detailUrl="/foo/bar"
|
||||
isSelected={false}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
@ -41,12 +49,16 @@ describe('<CredentialListItem />', () => {
|
||||
CredentialsAPI.copy.mockResolvedValue();
|
||||
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
credential={mockCredentials.results[0]}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
credential={mockCredentials.results[0]}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
await act(async () =>
|
||||
@ -60,12 +72,16 @@ describe('<CredentialListItem />', () => {
|
||||
CredentialsAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
onSelect={() => {}}
|
||||
credential={mockCredentials.results[0]}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
onSelect={() => {}}
|
||||
credential={mockCredentials.results[0]}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
@ -77,12 +93,16 @@ describe('<CredentialListItem />', () => {
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
onSelect={() => {}}
|
||||
credential={mockCredentials.results[1]}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<CredentialListItem
|
||||
isSelected={false}
|
||||
detailUrl="/foo/bar"
|
||||
onSelect={() => {}}
|
||||
credential={mockCredentials.results[1]}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
@ -270,7 +270,7 @@ function CredentialForm({
|
||||
<Button
|
||||
id="credential-form-cancel-button"
|
||||
aria-label={i18n._(t`Cancel`)}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
|
||||
@ -60,7 +60,7 @@ function CredentialTypeListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -23,7 +23,7 @@ import JobList from '../../components/JobList';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import LineChart from './shared/LineChart';
|
||||
import Count from './shared/Count';
|
||||
import DashboardTemplateList from './shared/DashboardTemplateList';
|
||||
import TemplateList from '../../components/TemplateList';
|
||||
|
||||
const Counts = styled.div`
|
||||
display: grid;
|
||||
@ -247,7 +247,9 @@ function Dashboard({ i18n }) {
|
||||
</Fragment>
|
||||
)}
|
||||
{activeTabId === 1 && <JobList defaultParams={{ page_size: 5 }} />}
|
||||
{activeTabId === 2 && <DashboardTemplateList />}
|
||||
{activeTabId === 2 && (
|
||||
<TemplateList defaultParams={{ page_size: 5 }} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</MainPageSection>
|
||||
|
||||
@ -44,7 +44,7 @@ describe('<Dashboard />', () => {
|
||||
.simulate('click');
|
||||
});
|
||||
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', () => {
|
||||
|
||||
@ -1,286 +0,0 @@
|
||||
import React, { Fragment, useEffect, useState, useCallback } from 'react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { Card, DropdownItem } from '@patternfly/react-core';
|
||||
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DatalistToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import { getQSConfig, parseQueryString } from '../../../util/qs';
|
||||
import useWsTemplates from '../../../util/useWsTemplates';
|
||||
import AddDropDownButton from '../../../components/AddDropDownButton';
|
||||
|
||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
||||
|
||||
const QS_CONFIG = getQSConfig(
|
||||
'template',
|
||||
{
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
type: 'job_template,workflow_job_template',
|
||||
},
|
||||
['id', 'page', 'page_size']
|
||||
);
|
||||
|
||||
function DashboardTemplateList({ i18n }) {
|
||||
// The type value in const QS_CONFIG below does not have a space between job_template and
|
||||
// workflow_job_template so the params sent to the API match what the api expects.
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
const {
|
||||
result: {
|
||||
results,
|
||||
count,
|
||||
jtActions,
|
||||
wfjtActions,
|
||||
relatedSearchableKeys,
|
||||
searchableKeys,
|
||||
},
|
||||
error: contentError,
|
||||
isLoading,
|
||||
request: fetchTemplates,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const responses = await Promise.all([
|
||||
UnifiedJobTemplatesAPI.read(params),
|
||||
JobTemplatesAPI.readOptions(),
|
||||
WorkflowJobTemplatesAPI.readOptions(),
|
||||
UnifiedJobTemplatesAPI.readOptions(),
|
||||
]);
|
||||
return {
|
||||
results: responses[0].data.results,
|
||||
count: responses[0].data.count,
|
||||
jtActions: responses[1].data.actions,
|
||||
wfjtActions: responses[2].data.actions,
|
||||
relatedSearchableKeys: (
|
||||
responses[3]?.data?.related_search_fields || []
|
||||
).map(val => val.slice(0, -8)),
|
||||
searchableKeys: Object.keys(
|
||||
responses[3].data.actions?.GET || {}
|
||||
).filter(key => responses[3].data.actions?.GET[key].filterable),
|
||||
};
|
||||
}, [location]),
|
||||
{
|
||||
results: [],
|
||||
count: 0,
|
||||
jtActions: {},
|
||||
wfjtActions: {},
|
||||
relatedSearchableKeys: [],
|
||||
searchableKeys: [],
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
const templates = useWsTemplates(results);
|
||||
|
||||
const isAllSelected =
|
||||
selected.length === templates.length && selected.length > 0;
|
||||
const {
|
||||
isLoading: isDeleteLoading,
|
||||
deleteItems: deleteTemplates,
|
||||
deletionError,
|
||||
clearDeletionError,
|
||||
} = useDeleteItems(
|
||||
useCallback(() => {
|
||||
return Promise.all(
|
||||
selected.map(({ type, id }) => {
|
||||
if (type === 'job_template') {
|
||||
return JobTemplatesAPI.destroy(id);
|
||||
}
|
||||
if (type === 'workflow_job_template') {
|
||||
return WorkflowJobTemplatesAPI.destroy(id);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}, [selected]),
|
||||
{
|
||||
qsConfig: QS_CONFIG,
|
||||
allItemsSelected: isAllSelected,
|
||||
fetchItems: fetchTemplates,
|
||||
}
|
||||
);
|
||||
|
||||
const handleTemplateDelete = async () => {
|
||||
await deleteTemplates();
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleSelectAll = isSelected => {
|
||||
setSelected(isSelected ? [...templates] : []);
|
||||
};
|
||||
|
||||
const handleSelect = template => {
|
||||
if (selected.some(s => s.id === template.id)) {
|
||||
setSelected(selected.filter(s => s.id !== template.id));
|
||||
} else {
|
||||
setSelected(selected.concat(template));
|
||||
}
|
||||
};
|
||||
|
||||
const canAddJT =
|
||||
jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST');
|
||||
const canAddWFJT =
|
||||
wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST');
|
||||
|
||||
const addTemplate = i18n._(t`Add job template`);
|
||||
const addWFTemplate = i18n._(t`Add workflow template`);
|
||||
const addButton = (
|
||||
<AddDropDownButton
|
||||
key="add"
|
||||
dropdownItems={[
|
||||
<DropdownItem
|
||||
key={addTemplate}
|
||||
component={Link}
|
||||
to="/templates/job_template/add/"
|
||||
aria-label={addTemplate}
|
||||
>
|
||||
{addTemplate}
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
component={Link}
|
||||
to="/templates/workflow_job_template/add/"
|
||||
key={addWFTemplate}
|
||||
aria-label={addWFTemplate}
|
||||
>
|
||||
{addWFTemplate}
|
||||
</DropdownItem>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={templates}
|
||||
itemCount={count}
|
||||
pluralizedItemName={i18n._(t`Templates`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
onRowClick={handleSelect}
|
||||
toolbarSearchColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name__icontains',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Description`),
|
||||
key: 'description__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'or__type',
|
||||
options: [
|
||||
[`job_template`, i18n._(t`Job Template`)],
|
||||
[`workflow_job_template`, i18n._(t`Workflow Template`)],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Playbook name`),
|
||||
key: 'job_template__playbook__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created By (Username)`),
|
||||
key: 'created_by__username__icontains',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified By (Username)`),
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Inventory`),
|
||||
key: 'job_template__inventory__id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last Job Run`),
|
||||
key: 'last_job_run',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified`),
|
||||
key: 'modified',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Project`),
|
||||
key: 'jobtemplate__project__id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Type`),
|
||||
key: 'type',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
toolbarRelatedSearchableKeys={relatedSearchableKeys}
|
||||
renderToolbar={props => (
|
||||
<DatalistToolbar
|
||||
{...props}
|
||||
showSelectAll
|
||||
isAllSelected={isAllSelected}
|
||||
onSelectAll={handleSelectAll}
|
||||
qsConfig={QS_CONFIG}
|
||||
additionalControls={[
|
||||
...(canAddJT || canAddWFJT ? [addButton] : []),
|
||||
<ToolbarDeleteButton
|
||||
key="delete"
|
||||
onDelete={handleTemplateDelete}
|
||||
itemsToDelete={selected}
|
||||
pluralizedItemName="Templates"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={template => (
|
||||
<DashboardTemplateListItem
|
||||
key={template.id}
|
||||
value={template.name}
|
||||
template={template}
|
||||
detailUrl={`/templates/${template.type}/${template.id}`}
|
||||
onSelect={() => handleSelect(template)}
|
||||
isSelected={selected.some(row => row.id === template.id)}
|
||||
fetchTemplates={fetchTemplates}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={(canAddJT || canAddWFJT) && addButton}
|
||||
/>
|
||||
</Card>
|
||||
<AlertModal
|
||||
aria-label={i18n._(t`Deletion Error`)}
|
||||
isOpen={deletionError}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={clearDeletionError}
|
||||
>
|
||||
{i18n._(t`Failed to delete one or more templates.`)}
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(DashboardTemplateList);
|
||||
@ -1,336 +0,0 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
JobTemplatesAPI,
|
||||
UnifiedJobTemplatesAPI,
|
||||
WorkflowJobTemplatesAPI,
|
||||
} from '../../../api';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
|
||||
import DashboardTemplateList from './DashboardTemplateList';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
const mockTemplates = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Job Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
edit: true,
|
||||
copy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Job Template 2',
|
||||
url: '/templates/job_template/2',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Job Template 3',
|
||||
url: '/templates/job_template/3',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Workflow Job Template 1',
|
||||
url: '/templates/workflow_job_template/4',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Workflow Job Template 2',
|
||||
url: '/templates/workflow_job_template/5',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('<DashboardTemplateList />', () => {
|
||||
let debug;
|
||||
beforeEach(() => {
|
||||
UnifiedJobTemplatesAPI.read.mockResolvedValue({
|
||||
data: {
|
||||
count: mockTemplates.length,
|
||||
results: mockTemplates,
|
||||
},
|
||||
});
|
||||
|
||||
UnifiedJobTemplatesAPI.readOptions.mockResolvedValue({
|
||||
data: {
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
debug = global.console.debug; // eslint-disable-line prefer-destructuring
|
||||
global.console.debug = () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.console.debug = debug;
|
||||
});
|
||||
|
||||
test('initially renders successfully', async () => {
|
||||
await act(async () => {
|
||||
mountWithContexts(
|
||||
<DashboardTemplateList
|
||||
match={{ path: '/templates', url: '/templates' }}
|
||||
location={{ search: '', pathname: '/templates' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('Templates are retrieved from the api and the components finishes loading', async () => {
|
||||
let wrapper;
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
});
|
||||
expect(UnifiedJobTemplatesAPI.read).toBeCalled();
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
expect(wrapper.find('DashboardTemplateListItem').length).toEqual(5);
|
||||
});
|
||||
|
||||
test('handleSelect is called when a template list item is selected', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const checkBox = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.find('input');
|
||||
|
||||
checkBox.simulate('change', {
|
||||
target: {
|
||||
id: 2,
|
||||
name: 'Job Template 2',
|
||||
url: '/templates/job_template/2',
|
||||
type: 'job_template',
|
||||
summary_fields: { user_capabilities: { delete: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.prop('isSelected')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('handleSelectAll is called when a template list item is selected', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(false);
|
||||
|
||||
const toolBarCheckBox = wrapper.find('Checkbox#select-all');
|
||||
act(() => {
|
||||
toolBarCheckBox.prop('onChange')(true);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Checkbox#select-all').prop('isChecked')).toBe(true);
|
||||
});
|
||||
|
||||
test('delete button is disabled if user does not have delete capabilities on a selected template', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const deleteableItem = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(0)
|
||||
.find('input');
|
||||
const nonDeleteableItem = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(4)
|
||||
.find('input');
|
||||
|
||||
deleteableItem.simulate('change', {
|
||||
id: 1,
|
||||
name: 'Job Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
deleteableItem.simulate('change', {
|
||||
id: 1,
|
||||
name: 'Job Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
nonDeleteableItem.simulate('change', {
|
||||
id: 5,
|
||||
name: 'Workflow Job Template 2',
|
||||
url: '/templates/workflow_job_template/5',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('Button[aria-label="Delete"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('api is called to delete templates for each selected template.', async () => {
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const jobTemplate = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.find('input');
|
||||
const workflowJobTemplate = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(3)
|
||||
.find('input');
|
||||
|
||||
jobTemplate.simulate('change', {
|
||||
target: {
|
||||
id: 2,
|
||||
name: 'Job Template 2',
|
||||
url: '/templates/job_template/2',
|
||||
type: 'job_template',
|
||||
summary_fields: { user_capabilities: { delete: true } },
|
||||
},
|
||||
});
|
||||
|
||||
workflowJobTemplate.simulate('change', {
|
||||
target: {
|
||||
id: 4,
|
||||
name: 'Workflow Job Template 1',
|
||||
url: '/templates/workflow_job_template/4',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
.find('button[aria-label="confirm delete"]')
|
||||
.prop('onClick')();
|
||||
});
|
||||
expect(JobTemplatesAPI.destroy).toBeCalledWith(2);
|
||||
expect(WorkflowJobTemplatesAPI.destroy).toBeCalledWith(4);
|
||||
});
|
||||
|
||||
test('error is shown when template not successfully deleted from api', async () => {
|
||||
JobTemplatesAPI.destroy.mockRejectedValue(
|
||||
new Error({
|
||||
response: {
|
||||
config: {
|
||||
method: 'delete',
|
||||
url: '/api/v2/job_templates/1',
|
||||
},
|
||||
data: 'An error occurred',
|
||||
},
|
||||
})
|
||||
);
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
const checkBox = wrapper
|
||||
.find('DashboardTemplateListItem')
|
||||
.at(1)
|
||||
.find('input');
|
||||
|
||||
checkBox.simulate('change', {
|
||||
target: {
|
||||
id: 'a',
|
||||
name: 'Job Template 2',
|
||||
url: '/templates/job_template/2',
|
||||
type: 'job_template',
|
||||
summary_fields: { user_capabilities: { delete: true } },
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Delete"]').prop('onClick')();
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
.find('button[aria-label="confirm delete"]')
|
||||
.prop('onClick')();
|
||||
});
|
||||
|
||||
await waitForElement(
|
||||
wrapper,
|
||||
'Modal[aria-label="Deletion Error"]',
|
||||
el => el.props().isOpen === true && el.props().title === 'Error!'
|
||||
);
|
||||
});
|
||||
test('should properly copy template', async () => {
|
||||
JobTemplatesAPI.copy.mockResolvedValue({});
|
||||
const wrapper = mountWithContexts(<DashboardTemplateList />);
|
||||
await act(async () => {
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
||||
expect(UnifiedJobTemplatesAPI.read).toHaveBeenCalled();
|
||||
wrapper.update();
|
||||
});
|
||||
});
|
||||
@ -1,179 +0,0 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { t } from '@lingui/macro';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import {
|
||||
ExclamationTriangleIcon,
|
||||
PencilAltIcon,
|
||||
ProjectDiagramIcon,
|
||||
RocketIcon,
|
||||
} from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
import { timeOfDay } from '../../../util/dates';
|
||||
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
|
||||
import LaunchButton from '../../../components/LaunchButton';
|
||||
import Sparkline from '../../../components/Sparkline';
|
||||
import { toTitleCase } from '../../../util/strings';
|
||||
import CopyButton from '../../../components/CopyButton';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: repeat(4, 40px);
|
||||
`;
|
||||
|
||||
function DashboardTemplateListItem({
|
||||
i18n,
|
||||
template,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
fetchTemplates,
|
||||
}) {
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
const labelId = `check-action-${template.id}`;
|
||||
|
||||
const copyTemplate = useCallback(async () => {
|
||||
if (template.type === 'job_template') {
|
||||
await JobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
} else {
|
||||
await WorkflowJobTemplatesAPI.copy(template.id, {
|
||||
name: `${template.name} @ ${timeOfDay()}`,
|
||||
});
|
||||
}
|
||||
await fetchTemplates();
|
||||
}, [fetchTemplates, template.id, template.name, template.type]);
|
||||
|
||||
const handleCopyStart = useCallback(() => {
|
||||
setIsDisabled(true);
|
||||
}, []);
|
||||
|
||||
const handleCopyFinish = useCallback(() => {
|
||||
setIsDisabled(false);
|
||||
}, []);
|
||||
|
||||
const missingResourceIcon =
|
||||
template.type === 'job_template' &&
|
||||
(!template.summary_fields.project ||
|
||||
(!template.summary_fields.inventory &&
|
||||
!template.ask_inventory_on_launch));
|
||||
return (
|
||||
<DataListItem aria-labelledby={labelId} id={`${template.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
isDisabled={isDisabled}
|
||||
id={`select-jobTemplate-${template.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name" id={labelId}>
|
||||
<span>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{template.name}</b>
|
||||
</Link>
|
||||
</span>
|
||||
{missingResourceIcon && (
|
||||
<span>
|
||||
<Tooltip
|
||||
content={i18n._(
|
||||
t`Resources are missing from this template.`
|
||||
)}
|
||||
position="right"
|
||||
>
|
||||
<ExclamationTriangleIcon css="color: #c9190b; margin-left: 20px;" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="type">
|
||||
{toTitleCase(template.type)}
|
||||
</DataListCell>,
|
||||
<DataListCell key="sparkline">
|
||||
<Sparkline jobs={template.summary_fields.recent_jobs} />
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction aria-label="actions" aria-labelledby={labelId}>
|
||||
{template.type === 'workflow_job_template' && (
|
||||
<Tooltip content={i18n._(t`Visualizer`)} position="top">
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Visualizer`)}
|
||||
css="grid-column: 1"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/workflow_job_template/${template.id}/visualizer`}
|
||||
>
|
||||
<ProjectDiagramIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.start && (
|
||||
<Tooltip content={i18n._(t`Launch Template`)} position="top">
|
||||
<LaunchButton resource={template}>
|
||||
{({ handleLaunch }) => (
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Launch template`)}
|
||||
css="grid-column: 2"
|
||||
variant="plain"
|
||||
onClick={handleLaunch}
|
||||
>
|
||||
<RocketIcon />
|
||||
</Button>
|
||||
)}
|
||||
</LaunchButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.edit && (
|
||||
<Tooltip content={i18n._(t`Edit Template`)} position="top">
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
aria-label={i18n._(t`Edit Template`)}
|
||||
css="grid-column: 3"
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/templates/${template.type}/${template.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{template.summary_fields.user_capabilities.copy && (
|
||||
<CopyButton
|
||||
helperText={{
|
||||
tooltip: i18n._(t`Copy Template`),
|
||||
errorMessage: i18n._(t`Failed to copy template.`),
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
onCopyStart={handleCopyStart}
|
||||
onCopyFinish={handleCopyFinish}
|
||||
copyItem={copyTemplate}
|
||||
/>
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardTemplateListItem as _TemplateListItem };
|
||||
export default withI18n()(DashboardTemplateListItem);
|
||||
@ -1,268 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
|
||||
import { JobTemplatesAPI } from '../../../api';
|
||||
|
||||
import mockJobTemplateData from './data.job_template.json';
|
||||
import DashboardTemplateListItem from './DashboardTemplateListItem';
|
||||
|
||||
jest.mock('../../../api');
|
||||
|
||||
describe('<DashboardTemplateListItem />', () => {
|
||||
test('launch button shown to users with start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeTruthy();
|
||||
});
|
||||
test('launch button hidden from users without start capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
start: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('LaunchButton').exists()).toBeFalsy();
|
||||
});
|
||||
test('edit button shown to users with edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: true,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeTruthy();
|
||||
});
|
||||
test('edit button hidden from users without edit capabilities', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
test('missing resource icon is shown.', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(true);
|
||||
});
|
||||
test('missing resource icon is not shown when there is a project and an inventory.', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
project: { name: 'Foo', id: 2 },
|
||||
inventory: { name: 'Bar', id: 2 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('missing resource icon is not shown when inventory is prompt_on_launch, and a project', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'job_template',
|
||||
ask_inventory_on_launch: true,
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
project: { name: 'Foo', id: 2 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('missing resource icon is not shown type is workflow_job_template', () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
url: '/templates/job_template/1',
|
||||
type: 'workflow_job_template',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ExclamationTriangleIcon').exists()).toBe(false);
|
||||
});
|
||||
test('clicking on template from templates list navigates properly', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/templates'],
|
||||
});
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
id: 1,
|
||||
name: 'Template 1',
|
||||
summary_fields: {
|
||||
user_capabilities: {
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ context: { router: { history } } }
|
||||
);
|
||||
wrapper.find('Link').simulate('click', { button: 0 });
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/templates/job_template/1/details'
|
||||
);
|
||||
});
|
||||
test('should call api to copy template', async () => {
|
||||
JobTemplatesAPI.copy.mockResolvedValue();
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
expect(JobTemplatesAPI.copy).toHaveBeenCalled();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render proper alert modal on copy error', async () => {
|
||||
JobTemplatesAPI.copy.mockRejectedValue(new Error());
|
||||
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
await act(async () =>
|
||||
wrapper.find('Button[aria-label="Copy"]').prop('onClick')()
|
||||
);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('Modal').prop('isOpen')).toBe(true);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should not render copy button', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
summary_fields: { user_capabilities: { copy: false } },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('CopyButton').length).toBe(0);
|
||||
});
|
||||
|
||||
test('should render visualizer button for workflow', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={{
|
||||
...mockJobTemplateData,
|
||||
type: 'workflow_job_template',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should not render visualizer button for job template', async () => {
|
||||
const wrapper = mountWithContexts(
|
||||
<DashboardTemplateListItem
|
||||
isSelected={false}
|
||||
detailUrl="/templates/job_template/1/details"
|
||||
template={mockJobTemplateData}
|
||||
/>
|
||||
);
|
||||
expect(wrapper.find('ProjectDiagramIcon').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@ -42,7 +42,7 @@ function HostGroupItem({ i18n, group, inventoryId, isSelected, onSelect }) {
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -8,10 +8,14 @@ import { HostsAPI } from '../../../api';
|
||||
import AlertModal from '../../../components/AlertModal';
|
||||
import DataListToolbar from '../../../components/DataListToolbar';
|
||||
import ErrorDetail from '../../../components/ErrorDetail';
|
||||
import PaginatedDataList, {
|
||||
import {
|
||||
ToolbarAddButton,
|
||||
ToolbarDeleteButton,
|
||||
} from '../../../components/PaginatedDataList';
|
||||
import PaginatedTable, {
|
||||
HeaderRow,
|
||||
HeaderCell,
|
||||
} from '../../../components/PaginatedTable';
|
||||
import useRequest, { useDeleteItems } from '../../../util/useRequest';
|
||||
import {
|
||||
encodeQueryString,
|
||||
@ -130,7 +134,7 @@ function HostList({ i18n }) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<PaginatedDataList
|
||||
<PaginatedTable
|
||||
contentError={contentError}
|
||||
hasContentLoading={isLoading || isDeleteLoading}
|
||||
items={hosts}
|
||||
@ -157,14 +161,15 @@ function HostList({ i18n }) {
|
||||
key: 'modified_by__username__icontains',
|
||||
},
|
||||
]}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
]}
|
||||
toolbarSearchableKeys={searchableKeys}
|
||||
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 => (
|
||||
<DataListToolbar
|
||||
{...props}
|
||||
@ -193,13 +198,14 @@ function HostList({ i18n }) {
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
renderItem={host => (
|
||||
renderRow={(host, index) => (
|
||||
<HostListItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
detailUrl={`${match.url}/${host.id}/details`}
|
||||
isSelected={selected.some(row => row.id === host.id)}
|
||||
onSelect={() => handleSelect(host)}
|
||||
rowIndex={index}
|
||||
/>
|
||||
)}
|
||||
emptyStateControls={
|
||||
|
||||
@ -134,8 +134,9 @@ describe('<HostList />', () => {
|
||||
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('input#select-host-1')
|
||||
.closest('DataListCheck')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
@ -147,8 +148,9 @@ describe('<HostList />', () => {
|
||||
).toEqual(true);
|
||||
act(() => {
|
||||
wrapper
|
||||
.find('input#select-host-1')
|
||||
.closest('DataListCheck')
|
||||
.find('.pf-c-table__check')
|
||||
.first()
|
||||
.find('input')
|
||||
.invoke('onChange')();
|
||||
});
|
||||
wrapper.update();
|
||||
|
||||
@ -1,91 +1,67 @@
|
||||
import 'styled-components/macro';
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
import { string, bool, func } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Button,
|
||||
DataListAction as _DataListAction,
|
||||
DataListCheck,
|
||||
DataListItem,
|
||||
DataListItemRow,
|
||||
DataListItemCells,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import { Tr, Td } from '@patternfly/react-table';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilAltIcon } from '@patternfly/react-icons';
|
||||
import styled from 'styled-components';
|
||||
import DataListCell from '../../../components/DataListCell';
|
||||
|
||||
import Sparkline from '../../../components/Sparkline';
|
||||
import { ActionsTd, ActionItem } from '../../../components/PaginatedTable';
|
||||
import { Host } from '../../../types';
|
||||
import HostToggle from '../../../components/HostToggle';
|
||||
|
||||
const DataListAction = styled(_DataListAction)`
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 24px;
|
||||
grid-template-columns: 92px 40px;
|
||||
`;
|
||||
|
||||
function HostListItem({ i18n, host, isSelected, onSelect, detailUrl }) {
|
||||
function HostListItem({
|
||||
i18n,
|
||||
host,
|
||||
isSelected,
|
||||
onSelect,
|
||||
detailUrl,
|
||||
rowIndex,
|
||||
}) {
|
||||
const labelId = `check-action-${host.id}`;
|
||||
|
||||
return (
|
||||
<DataListItem key={host.id} aria-labelledby={labelId} id={`${host.id}`}>
|
||||
<DataListItemRow>
|
||||
<DataListCheck
|
||||
id={`select-host-${host.id}`}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
aria-labelledby={labelId}
|
||||
/>
|
||||
<DataListItemCells
|
||||
dataListCells={[
|
||||
<DataListCell key="name">
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</DataListCell>,
|
||||
<DataListCell key="recentJobs">
|
||||
<Sparkline jobs={host.summary_fields.recent_jobs} />
|
||||
</DataListCell>,
|
||||
<DataListCell key="inventory">
|
||||
{host.summary_fields.inventory && (
|
||||
<Fragment>
|
||||
<b css="margin-right: 24px">{i18n._(t`Inventory`)}</b>
|
||||
<Link
|
||||
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
|
||||
>
|
||||
{host.summary_fields.inventory.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
)}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
<Tr id={`host-row-${host.id}`}>
|
||||
<Td
|
||||
select={{
|
||||
rowIndex,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}}
|
||||
dataLabel={i18n._(t`Selected`)}
|
||||
/>
|
||||
<Td id={labelId} dataLabel={i18n._(t`Name`)}>
|
||||
<Link to={`${detailUrl}`}>
|
||||
<b>{host.name}</b>
|
||||
</Link>
|
||||
</Td>
|
||||
<Td dataLabel={i18n._(t`Inventory`)}>
|
||||
{host.summary_fields.inventory && (
|
||||
<Link
|
||||
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
|
||||
>
|
||||
{host.summary_fields.inventory.name}
|
||||
</Link>
|
||||
)}
|
||||
</Td>
|
||||
<ActionsTd dataLabel={i18n._(t`Actions`)} gridColumns="auto 40px">
|
||||
<HostToggle host={host} />
|
||||
<ActionItem
|
||||
visible={host.summary_fields.user_capabilities.edit}
|
||||
tooltip={i18n._(t`Edit Host`)}
|
||||
>
|
||||
<HostToggle host={host} />
|
||||
{host.summary_fields.user_capabilities.edit ? (
|
||||
<Tooltip content={i18n._(t`Edit Host`)} position="top">
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Host`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/hosts/${host.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</DataListAction>
|
||||
</DataListItemRow>
|
||||
</DataListItem>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit Host`)}
|
||||
variant="plain"
|
||||
component={Link}
|
||||
to={`/hosts/${host.id}/edit`}
|
||||
>
|
||||
<PencilAltIcon />
|
||||
</Button>
|
||||
</ActionItem>
|
||||
</ActionsTd>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -25,12 +25,16 @@ describe('<HostsListItem />', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountWithContexts(
|
||||
<HostsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={mockHost}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<HostsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={mockHost}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
@ -46,12 +50,16 @@ describe('<HostsListItem />', () => {
|
||||
const copyMockHost = Object.assign({}, mockHost);
|
||||
copyMockHost.summary_fields.user_capabilities.edit = false;
|
||||
wrapper = mountWithContexts(
|
||||
<HostsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={copyMockHost}
|
||||
/>
|
||||
<table>
|
||||
<tbody>
|
||||
<HostsListItem
|
||||
isSelected={false}
|
||||
detailUrl="/host/1"
|
||||
onSelect={() => {}}
|
||||
host={copyMockHost}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
@ -173,7 +173,7 @@ function InstanceGroupListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -113,7 +113,7 @@ function InstanceListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -66,7 +66,7 @@ function InventoryGroupHostListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -48,7 +48,7 @@ function InventoryGroupItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -48,7 +48,7 @@ function InventoryHostGroupItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -59,7 +59,7 @@ function InventoryHostItem(props) {
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -195,9 +195,6 @@ function InventoryList({ i18n }) {
|
||||
<HeaderCell>{i18n._(t`Status`)}</HeaderCell>
|
||||
<HeaderCell>{i18n._(t`Type`)}</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>
|
||||
</HeaderRow>
|
||||
}
|
||||
|
||||
@ -89,11 +89,6 @@ function InventoryListItem({
|
||||
{inventory?.summary_fields?.organization?.name}
|
||||
</Link>
|
||||
</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 ? (
|
||||
<Td dataLabel={i18n._(t`Groups`)}>
|
||||
<Label color="red">{i18n._(t`Pending delete`)}</Label>
|
||||
|
||||
@ -56,7 +56,7 @@ function InventoryRelatedGroupListItem({
|
||||
]}
|
||||
/>
|
||||
<DataListAction
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
aria-labelledby={labelId}
|
||||
id={labelId}
|
||||
>
|
||||
|
||||
@ -88,7 +88,7 @@ function InventorySourceListItem({
|
||||
<DataListAction
|
||||
id="actions"
|
||||
aria-labelledby="actions"
|
||||
aria-label="actions"
|
||||
aria-label={i18n._(t`actions`)}
|
||||
>
|
||||
{source.summary_fields.user_capabilities.start && (
|
||||
<InventorySourceSyncButton source={source} />
|
||||
|
||||
@ -152,7 +152,7 @@ function SmartInventoryDetail({ inventory, i18n }) {
|
||||
{user_capabilities?.edit && (
|
||||
<Button
|
||||
component={Link}
|
||||
aria-label="edit"
|
||||
aria-label={i18n._(t`edit`)}
|
||||
to={`/inventories/smart_inventory/${id}/edit`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
|
||||
@ -105,7 +105,7 @@ const InventoryGroupsDeleteModal = ({
|
||||
<Button
|
||||
aria-label={i18n._(t`Close`)}
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
variant="secondary"
|
||||
variant="link"
|
||||
key="cancel"
|
||||
>
|
||||
{i18n._(t`Cancel`)}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
DetailList,
|
||||
Detail,
|
||||
UserDateDetail,
|
||||
LaunchedByDetail,
|
||||
} from '../../../components/DetailList';
|
||||
import { CardBody, CardActionsRow } from '../../../components/Card';
|
||||
import ChipGroup from '../../../components/ChipGroup';
|
||||
@ -53,35 +54,6 @@ const VERBOSITY = {
|
||||
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 }) {
|
||||
const {
|
||||
created_by,
|
||||
@ -107,9 +79,6 @@ function JobDetail({ job, i18n }) {
|
||||
workflow_job: i18n._(t`Workflow Job`),
|
||||
};
|
||||
|
||||
const { value: launchedByValue, link: launchedByLink } =
|
||||
getLaunchedByDetails(job) || {};
|
||||
|
||||
const deleteJob = async () => {
|
||||
try {
|
||||
switch (job.type) {
|
||||
@ -137,7 +106,7 @@ function JobDetail({ job, i18n }) {
|
||||
}
|
||||
};
|
||||
|
||||
const isIsolatedInstanceGroup = item => {
|
||||
const buildInstanceGroupLink = item => {
|
||||
if (item.is_isolated) {
|
||||
return (
|
||||
<>
|
||||
@ -153,16 +122,26 @@ function JobDetail({ job, i18n }) {
|
||||
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 (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
{/* TODO: hookup status to websockets */}
|
||||
<Detail
|
||||
fullWidth={Boolean(job.job_explanation)}
|
||||
label={i18n._(t`Status`)}
|
||||
value={
|
||||
<StatusDetailValue>
|
||||
{job.status && <StatusIcon status={job.status} />}
|
||||
{toTitleCase(job.status)}
|
||||
{job.job_explanation
|
||||
? job.job_explanation
|
||||
: toTitleCase(job.status)}
|
||||
</StatusDetailValue>
|
||||
}
|
||||
/>
|
||||
@ -207,16 +186,7 @@ function JobDetail({ job, i18n }) {
|
||||
/>
|
||||
)}
|
||||
<Detail label={i18n._(t`Job Type`)} value={jobTypes[job.type]} />
|
||||
<Detail
|
||||
label={i18n._(t`Launched By`)}
|
||||
value={
|
||||
launchedByLink ? (
|
||||
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
||||
) : (
|
||||
launchedByValue
|
||||
)
|
||||
}
|
||||
/>
|
||||
<LaunchedByDetail job={job} i18n={i18n} />
|
||||
{inventory && (
|
||||
<Detail
|
||||
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`Environment`)} value={job.custom_virtualenv} />
|
||||
<Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
|
||||
{instanceGroup && (
|
||||
{instanceGroup && !instanceGroup?.is_containerized && (
|
||||
<Detail
|
||||
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' &&
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { I18n } from '@lingui/react';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import styled from 'styled-components';
|
||||
import {
|
||||
@ -10,6 +10,7 @@ import {
|
||||
InfiniteLoader,
|
||||
List,
|
||||
} from 'react-virtualized';
|
||||
import { Button } from '@patternfly/react-core';
|
||||
import Ansi from 'ansi-to-html';
|
||||
import hasAnsi from 'has-ansi';
|
||||
import { AllHtmlEntities } from 'html-entities';
|
||||
@ -225,6 +226,7 @@ class JobOutput extends Component {
|
||||
this.state = {
|
||||
contentError: null,
|
||||
deletionError: null,
|
||||
cancelError: null,
|
||||
hasContentLoading: true,
|
||||
results: {},
|
||||
currentlyLoading: [],
|
||||
@ -232,6 +234,9 @@ class JobOutput extends Component {
|
||||
isHostModalOpen: false,
|
||||
hostEvent: {},
|
||||
cssMap: {},
|
||||
jobStatus: props.job.status ?? 'waiting',
|
||||
showCancelPrompt: false,
|
||||
cancelInProgress: false,
|
||||
};
|
||||
|
||||
this.cache = new CellMeasurerCache({
|
||||
@ -242,6 +247,9 @@ class JobOutput extends Component {
|
||||
this._isMounted = false;
|
||||
this.loadJobEvents = this.loadJobEvents.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.handleHostEventClick = this.handleHostEventClick.bind(this);
|
||||
this.handleHostModalClose = this.handleHostModalClose.bind(this);
|
||||
@ -261,11 +269,21 @@ class JobOutput extends Component {
|
||||
this._isMounted = true;
|
||||
this.loadJobEvents();
|
||||
|
||||
if (job.result_traceback) return;
|
||||
|
||||
connectJobSocket(job, data => {
|
||||
if (data.counter && data.counter > this.jobSocketCounter) {
|
||||
this.jobSocketCounter = data.counter;
|
||||
} else if (data.final_counter && data.unified_job_id === job.id) {
|
||||
this.jobSocketCounter = data.final_counter;
|
||||
if (data.group_name === 'job_events') {
|
||||
if (data.counter && data.counter > this.jobSocketCounter) {
|
||||
this.jobSocketCounter = data.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);
|
||||
@ -326,10 +344,32 @@ class JobOutput extends Component {
|
||||
});
|
||||
this._isMounted &&
|
||||
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 => {
|
||||
results[jobEvent.counter] = jobEvent;
|
||||
});
|
||||
return { results, remoteRowCount: count + 1 };
|
||||
return { results, remoteRowCount: count + countOffset };
|
||||
});
|
||||
} catch (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() {
|
||||
const { job, history } = this.props;
|
||||
try {
|
||||
@ -518,7 +578,7 @@ class JobOutput extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { job } = this.props;
|
||||
const { job, i18n } = this.props;
|
||||
|
||||
const {
|
||||
contentError,
|
||||
@ -528,6 +588,10 @@ class JobOutput extends Component {
|
||||
isHostModalOpen,
|
||||
remoteRowCount,
|
||||
cssMap,
|
||||
jobStatus,
|
||||
showCancelPrompt,
|
||||
cancelError,
|
||||
cancelInProgress,
|
||||
} = this.state;
|
||||
|
||||
if (hasContentLoading) {
|
||||
@ -553,7 +617,12 @@ class JobOutput extends Component {
|
||||
<StatusIcon status={job.status} />
|
||||
<h1>{job.name}</h1>
|
||||
</HeaderTitle>
|
||||
<OutputToolbar job={job} onDelete={this.handleDeleteJob} />
|
||||
<OutputToolbar
|
||||
job={job}
|
||||
jobStatus={jobStatus}
|
||||
onDelete={this.handleDeleteJob}
|
||||
onCancel={this.handleCancelOpen}
|
||||
/>
|
||||
</OutputHeader>
|
||||
<HostStatusBar counts={job.host_status_counts} />
|
||||
<PageControls
|
||||
@ -595,21 +664,65 @@ class JobOutput extends Component {
|
||||
<OutputFooter />
|
||||
</OutputWrapper>
|
||||
</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 && (
|
||||
<>
|
||||
<I18n>
|
||||
{({ i18n }) => (
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
onClose={() => this.setState({ deletionError: null })}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
>
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</I18n>
|
||||
<AlertModal
|
||||
isOpen={deletionError}
|
||||
variant="danger"
|
||||
onClose={() => this.setState({ deletionError: null })}
|
||||
title={i18n._(t`Job Delete Error`)}
|
||||
label={i18n._(t`Job Delete Error`)}
|
||||
>
|
||||
<ErrorDetail error={deletionError} />
|
||||
</AlertModal>
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
@ -618,4 +731,4 @@ class JobOutput extends Component {
|
||||
}
|
||||
|
||||
export { JobOutput as _JobOutput };
|
||||
export default withRouter(JobOutput);
|
||||
export default withI18n()(withRouter(JobOutput));
|
||||
|
||||
@ -4,6 +4,7 @@ import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { shape, func } from 'prop-types';
|
||||
import {
|
||||
MinusCircleIcon,
|
||||
DownloadIcon,
|
||||
RocketIcon,
|
||||
TrashAltIcon,
|
||||
@ -58,7 +59,7 @@ const OUTPUT_NO_COUNT_JOB_TYPES = [
|
||||
'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 playCount = job?.playbook_counts?.play_count;
|
||||
@ -148,19 +149,34 @@ const OutputToolbar = ({ i18n, job, onDelete }) => {
|
||||
</a>
|
||||
</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 && (
|
||||
<Tooltip content={i18n._(t`Delete Job`)}>
|
||||
<DeleteButton
|
||||
name={job.name}
|
||||
modalTitle={i18n._(t`Delete Job`)}
|
||||
onConfirm={onDelete}
|
||||
variant="plain"
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</DeleteButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{job.summary_fields.user_capabilities.delete &&
|
||||
['new', 'successful', 'failed', 'error', 'canceled'].includes(
|
||||
jobStatus
|
||||
) && (
|
||||
<Tooltip content={i18n._(t`Delete Job`)}>
|
||||
<DeleteButton
|
||||
name={job.name}
|
||||
modalTitle={i18n._(t`Delete Job`)}
|
||||
onConfirm={onDelete}
|
||||
variant="plain"
|
||||
>
|
||||
<TrashAltIcon />
|
||||
</DeleteButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@ -16,6 +16,7 @@ describe('<OutputToolbar />', () => {
|
||||
failures: 2,
|
||||
},
|
||||
}}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
@ -33,6 +34,7 @@ describe('<OutputToolbar />', () => {
|
||||
wrapper = mountWithContexts(
|
||||
<OutputToolbar
|
||||
job={{ ...mockJobData, type: 'system_job' }}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
@ -54,6 +56,7 @@ describe('<OutputToolbar />', () => {
|
||||
host_status_counts: {},
|
||||
playbook_counts: {},
|
||||
}}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
@ -74,6 +77,7 @@ describe('<OutputToolbar />', () => {
|
||||
...mockJobData,
|
||||
elapsed: 274265,
|
||||
}}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
@ -95,6 +99,7 @@ describe('<OutputToolbar />', () => {
|
||||
},
|
||||
},
|
||||
}}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
@ -113,6 +118,7 @@ describe('<OutputToolbar />', () => {
|
||||
},
|
||||
},
|
||||
}}
|
||||
jobStatus="successful"
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user