From 8265934c2d1c41c1ad314d4672bb6203cd5e10e9 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Wed, 28 Sep 2016 16:57:06 -0400 Subject: [PATCH 01/14] Use bubblewrap (https://github.com/projectatomic/bubblewrap) instead of proot. --- awx/main/utils.py | 11 +++++------ awx/settings/defaults.py | 12 ++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/awx/main/utils.py b/awx/main/utils.py index 0bb8ccc149..a4ef6a36d1 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -538,7 +538,7 @@ def check_proot_installed(): Check that proot is installed. ''' from django.conf import settings - cmd = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '--version'] + cmd = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--version'] try: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -566,8 +566,7 @@ def wrap_args_with_proot(args, cwd, **kwargs): - /tmp (except for own tmp files) ''' from django.conf import settings - new_args = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '-v', - str(getattr(settings, 'AWX_PROOT_VERBOSITY', '0')), '-r', '/'] + new_args = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--dev-bind', '/', '/'] hide_paths = ['/etc/tower', '/var/lib/awx', '/var/log', tempfile.gettempdir(), settings.PROJECTS_ROOT, settings.JOBOUTPUT_ROOT] @@ -582,7 +581,7 @@ def wrap_args_with_proot(args, cwd, **kwargs): handle, new_path = tempfile.mkstemp(dir=kwargs['proot_temp_dir']) os.close(handle) os.chmod(new_path, stat.S_IRUSR | stat.S_IWUSR) - new_args.extend(['-b', '%s:%s' % (new_path, path)]) + new_args.extend(['--bind', '%s' %(new_path,), '%s' % (path,)]) if 'private_data_dir' in kwargs: show_paths = [cwd, kwargs['private_data_dir']] else: @@ -595,8 +594,8 @@ def wrap_args_with_proot(args, cwd, **kwargs): for path in sorted(set(show_paths)): if not os.path.exists(path): continue - new_args.extend(['-b', '%s:%s' % (path, path)]) - new_args.extend(['-w', cwd]) + new_args.extend(['--bind', '%s' % (path,), '%s' % (path,)]) + new_args.extend(['--chdir', cwd]) new_args.extend(args) return new_args diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 9da2142c19..7d73d7c049 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -495,25 +495,25 @@ JOB_EVENT_MAX_QUEUE_SIZE = 100 # Flag to enable/disable updating hosts M2M when saving job events. CAPTURE_JOB_EVENT_HOSTS = False -# Enable proot support for running jobs (playbook runs only). +# Enable bubblewrap support for running jobs (playbook runs only). # Note: This setting may be overridden by database settings. AWX_PROOT_ENABLED = False -# Command/path to proot. -AWX_PROOT_CMD = 'proot' +# Command/path to bubblewrap. +AWX_PROOT_CMD = 'bwrap' -# Additional paths to hide from jobs using proot. +# Additional paths to hide from jobs using bubblewrap. # Note: This setting may be overridden by database settings. AWX_PROOT_HIDE_PATHS = [] -# Additional paths to show for jobs using proot. +# Additional paths to show for jobs using bubbelwrap. # Note: This setting may be overridden by database settings. AWX_PROOT_SHOW_PATHS = [] # Number of jobs to show as part of the job template history AWX_JOB_TEMPLATE_HISTORY = 10 -# The directory in which proot will create new temporary directories for its root +# The directory in which bubblewrap will create new temporary directories for its root # Note: This setting may be overridden by database settings. AWX_PROOT_BASE_PATH = "/tmp" From d4bffd31e7883291d978509f0d5a31f917bb33dc Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Wed, 28 Sep 2016 17:07:43 -0400 Subject: [PATCH 02/14] Update old tests. --- awx/main/tests/old/ad_hoc.py | 20 ++++++++++---------- awx/main/tests/old/tasks.py | 22 +++++++++++----------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/awx/main/tests/old/ad_hoc.py b/awx/main/tests/old/ad_hoc.py index ec3204e6d7..2e05ae8017 100644 --- a/awx/main/tests/old/ad_hoc.py +++ b/awx/main/tests/old/ad_hoc.py @@ -319,19 +319,19 @@ class RunAdHocCommandTest(BaseAdHocCommandTest): self.assertIn('ssh-agent', ad_hoc_command.job_args) self.assertNotIn('Bad passphrase', ad_hoc_command.result_stdout) - def test_run_with_proot(self): - # Only run test if proot is installed - cmd = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '--version'] + def test_run_with_bubblewrap(self): + # Only run test if bubblewrap is installed + cmd = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--version'] try: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.communicate() - has_proot = bool(proc.returncode == 0) + has_bubblewrap = bool(proc.returncode == 0) except (OSError, ValueError): - has_proot = False - if not has_proot: - self.skipTest('proot is not installed') - # Enable proot for this test. + has_bubblewrap = False + if not has_bubblewrap: + self.skipTest('bubblewrap is not installed') + # Enable bubblewrap for this test. settings.AWX_PROOT_ENABLED = True # Hide local settings path. settings.AWX_PROOT_HIDE_PATHS = [os.path.join(settings.BASE_DIR, 'settings')] @@ -362,8 +362,8 @@ class RunAdHocCommandTest(BaseAdHocCommandTest): self.check_ad_hoc_command_events(ad_hoc_command, 'ok') @mock.patch('awx.main.tasks.BaseTask.run_pexpect', return_value=('failed', 0)) - def test_run_with_proot_not_installed(self, ignore): - # Enable proot for this test, specify invalid proot cmd. + def test_run_with_bubblewrap_not_installed(self, ignore): + # Enable bubblewrap for this test, specify invalid bubblewrap cmd. settings.AWX_PROOT_ENABLED = True settings.AWX_PROOT_CMD = 'PR00T' ad_hoc_command = self.create_test_ad_hoc_command() diff --git a/awx/main/tests/old/tasks.py b/awx/main/tests/old/tasks.py index fdd30bf854..a80ea07b87 100644 --- a/awx/main/tests/old/tasks.py +++ b/awx/main/tests/old/tasks.py @@ -150,7 +150,7 @@ TEST_ASYNC_NOWAIT_PLAYBOOK = ''' ''' TEST_PROOT_PLAYBOOK = ''' -- name: test proot environment +- name: test bubblewrap environment hosts: test-group gather_facts: false connection: local @@ -1177,19 +1177,19 @@ class RunJobTest(BaseJobExecutionTest): @unittest.skipUnless(settings.BROKER_URL == 'redis://localhost/', 'Non-default Redis setup.') - def test_run_job_with_proot(self): - # Only run test if proot is installed - cmd = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '--version'] + def test_run_job_with_bubblewrap(self): + # Only run test if bubblewrap is installed + cmd = [getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), '--version'] try: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc.communicate() - has_proot = bool(proc.returncode == 0) + has_bubblewrap = bool(proc.returncode == 0) except (OSError, ValueError): - has_proot = False - if not has_proot: - self.skipTest('proot is not installed') - # Enable proot for this test. + has_bubblewrap = False + if not has_bubblewrap: + self.skipTest('bubblewrap is not installed') + # Enable bubblewrap for this test. settings.AWX_PROOT_ENABLED = True # Hide local settings path. settings.AWX_PROOT_HIDE_PATHS = [os.path.join(settings.BASE_DIR, 'settings')] @@ -1227,8 +1227,8 @@ class RunJobTest(BaseJobExecutionTest): job = Job.objects.get(pk=job.pk) self.check_job_result(job, 'successful') - def test_run_job_with_proot_not_installed(self): - # Enable proot for this test, specify invalid proot cmd. + def test_run_job_with_bubblewrap_not_installed(self): + # Enable bubblewrap for this test, specify invalid bubblewrap cmd. settings.AWX_PROOT_ENABLED = True settings.AWX_PROOT_CMD = 'PR00T' self.create_test_credential() From f3d4fb7d5ba2063a269a70d3b7e1be097e4e2ed4 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Wed, 28 Sep 2016 17:10:15 -0400 Subject: [PATCH 03/14] Use implementation-neutral label/descriptions for proot/bubblewrap configuration items. --- awx/main/conf.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/awx/main/conf.py b/awx/main/conf.py index e0d16e8542..ffce3ea61f 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -112,7 +112,7 @@ register( register( 'AWX_PROOT_ENABLED', field_class=fields.BooleanField, - label=_('Enable PRoot for Job Execution'), + label=_('Enable job isloation'), help_text=_('Isolates an Ansible job from protected parts of the Tower system to prevent exposing sensitive information.'), category=_('Jobs'), category_slug='jobs', @@ -121,8 +121,8 @@ register( register( 'AWX_PROOT_BASE_PATH', field_class=fields.CharField, - label=_('Base PRoot execution path'), - help_text=_('The location that PRoot will create its temporary working directory.'), + label=_('Job isolation execution path'), + help_text=_('Create temporary working directories for isolated jobs in this location.'), category=_('Jobs'), category_slug='jobs', ) @@ -130,8 +130,8 @@ register( register( 'AWX_PROOT_HIDE_PATHS', field_class=fields.StringListField, - label=_('Paths to hide from PRoot jobs'), - help_text=_('Extra paths to hide from PRoot isolated processes.'), + label=_('Paths to hide from isolated jobs'), + help_text=_('Additional paths to hide from isolated processes.'), category=_('Jobs'), category_slug='jobs', ) @@ -139,8 +139,8 @@ register( register( 'AWX_PROOT_SHOW_PATHS', field_class=fields.StringListField, - label=_('Paths to expose to PRoot jobs'), - help_text=_('Explicit whitelist of paths to expose to PRoot jobs.'), + label=_('Paths to expose to isolated jobs'), + help_text=_('Whitelist of paths that would otherwise be hidden to expose to isolated jobs.'), category=_('Jobs'), category_slug='jobs', ) From a2c972e513a9aeafc32dbef52979d0df3fe5d9c5 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 13 Oct 2016 12:44:13 -0400 Subject: [PATCH 04/14] Workflow status original commit --- awx/api/serializers.py | 31 +++++++-------- ...rkflow_job_template_workflow_nodes_list.md | 14 +++++++ awx/api/views.py | 14 +++++++ .../0045_v310_workflow_failure_condition.py | 24 ++++++++++++ awx/main/models/workflow.py | 25 +++++++++++- awx/main/scheduler/__init__.py | 8 ++-- .../tests/functional/models/test_workflow.py | 39 +++++++++++++++++++ .../tests/unit/models/test_workflow_unit.py | 4 +- 8 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 awx/api/templates/api/workflow_job_template_workflow_nodes_list.md create mode 100644 awx/main/migrations/0045_v310_workflow_failure_condition.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d6aa39f706..d681fac426 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2242,18 +2242,22 @@ class WorkflowJobListSerializer(WorkflowJobSerializer, UnifiedJobListSerializer) pass class WorkflowNodeBaseSerializer(BaseSerializer): - job_type = serializers.SerializerMethodField() - job_tags = serializers.SerializerMethodField() - limit = serializers.SerializerMethodField() - skip_tags = serializers.SerializerMethodField() + job_type = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + job_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + limit = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) + skip_tags = serializers.CharField(allow_blank=True, allow_null=True, required=False, default=None) success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) + fail_on_job_failure = serializers.BooleanField( + help_text=('If set to true, and if the job runs and fails, ' + 'the workflow is marked as failed.'), + default=True) class Meta: fields = ('*', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', - 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags') + 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags', 'fail_on_job_failure') def get_related(self, obj): res = super(WorkflowNodeBaseSerializer, self).get_related(obj) @@ -2261,17 +2265,12 @@ class WorkflowNodeBaseSerializer(BaseSerializer): res['unified_job_template'] = obj.unified_job_template.get_absolute_url() return res - def get_job_type(self, obj): - return obj.char_prompts.get('job_type', None) - - def get_job_tags(self, obj): - return obj.char_prompts.get('job_tags', None) - - def get_skip_tags(self, obj): - return obj.char_prompts.get('skip_tags', None) - - def get_limit(self, obj): - return obj.char_prompts.get('limit', None) + def validate(self, attrs): + # char_prompts go through different validation, so remove them here + for fd in ['job_type', 'job_tags', 'skip_tags', 'limit']: + if fd in attrs: + attrs.pop(fd) + return super(WorkflowNodeBaseSerializer, self).validate(attrs) class WorkflowJobTemplateNodeSerializer(WorkflowNodeBaseSerializer): diff --git a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md new file mode 100644 index 0000000000..e6b078a23f --- /dev/null +++ b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md @@ -0,0 +1,14 @@ +# Workflow Job Template Workflow Node List + +Workflow nodes reference templates to execute and define the ordering +in which to execute them. After a job in this workflow finishes, +the subsequent actions are to: + + - run nodes contained in "failure_nodes" or "always_nodes" if job failed + - run nodes contained in "success_nodes" or "always_nodes" if job succeeded + +The workflow is marked as failed if any jobs run as part of that workflow fail +and have the field `fail_on_job_failure` set to true. If not, the workflow +job is marked as successful. + +{% include "api/sub_list_create_api_view.md" %} \ No newline at end of file diff --git a/awx/api/views.py b/awx/api/views.py index 66980e141d..d22c030965 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2404,6 +2404,7 @@ class JobTemplateLabelList(DeleteLastUnattachLabelMixin, SubListCreateAttachDeta serializer_class = LabelSerializer parent_model = JobTemplate relationship = 'labels' + new_in_300 = True def post(self, request, *args, **kwargs): # If a label already exists in the database, attach it instead of erroring out @@ -2697,6 +2698,7 @@ class WorkflowJobTemplateList(ListCreateAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateListSerializer always_allow_superuser = False + new_in_310 = True # TODO: RBAC ''' @@ -2714,10 +2716,12 @@ class WorkflowJobTemplateDetail(RetrieveUpdateDestroyAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateSerializer always_allow_superuser = False + new_in_310 = True class WorkflowJobTemplateLabelList(JobTemplateLabelList): parent_model = WorkflowJobTemplate + new_in_310 = True # TODO: @@ -2725,6 +2729,7 @@ class WorkflowJobTemplateLaunch(GenericAPIView): model = WorkflowJobTemplate serializer_class = EmptySerializer + new_in_310 = True def get(self, request, *args, **kwargs): data = {} @@ -2750,6 +2755,12 @@ class WorkflowJobTemplateWorkflowNodesList(SubListCreateAPIView): parent_model = WorkflowJobTemplate relationship = 'workflow_job_template_nodes' parent_key = 'workflow_job_template' + new_in_310 = True + + def update_raw_data(self, data): + for fd in ['job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags']: + data[fd] = None + return super(WorkflowJobTemplateWorkflowNodesList, self).update_raw_data(data) # TODO: class WorkflowJobTemplateJobsList(SubListAPIView): @@ -2765,12 +2776,14 @@ class WorkflowJobList(ListCreateAPIView): model = WorkflowJob serializer_class = WorkflowJobListSerializer + new_in_310 = True # TODO: class WorkflowJobDetail(RetrieveDestroyAPIView): model = WorkflowJob serializer_class = WorkflowJobSerializer + new_in_310 = True class WorkflowJobWorkflowNodesList(SubListAPIView): @@ -2780,6 +2793,7 @@ class WorkflowJobWorkflowNodesList(SubListAPIView): parent_model = WorkflowJob relationship = 'workflow_job_nodes' parent_key = 'workflow_job' + new_in_310 = True class SystemJobTemplateList(ListAPIView): diff --git a/awx/main/migrations/0045_v310_workflow_failure_condition.py b/awx/main/migrations/0045_v310_workflow_failure_condition.py new file mode 100644 index 0000000000..9bafa0feb6 --- /dev/null +++ b/awx/main/migrations/0045_v310_workflow_failure_condition.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0044_v310_project_playbook_files'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobnode', + name='fail_on_job_failure', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='workflowjobtemplatenode', + name='fail_on_job_failure', + field=models.BooleanField(default=True), + ), + ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2848b38a4a..3bc0608cfc 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -60,6 +60,10 @@ class WorkflowNodeBase(CreatedModifiedModel): default=None, on_delete=models.SET_NULL, ) + fail_on_job_failure = models.BooleanField( + blank=True, + default=True, + ) # Prompting-related fields inventory = models.ForeignKey( 'Inventory', @@ -93,6 +97,22 @@ class WorkflowNodeBase(CreatedModifiedModel): data[fd] = self.char_prompts[fd] return data + @property + def job_type(self): + return self.char_prompts.get('job_type', None) + + @property + def job_tags(self): + return self.char_prompts.get('job_tags', None) + + @property + def skip_tags(self): + return self.char_prompts.get('skip_tags', None) + + @property + def limit(self): + return self.char_prompts.get('limit', None) + def get_prompts_warnings(self): ujt_obj = self.unified_job_template if ujt_obj is None: @@ -137,7 +157,7 @@ class WorkflowNodeBase(CreatedModifiedModel): Return field names that should be copied from template node to job node. ''' return ['workflow_job', 'unified_job_template', - 'inventory', 'credential', 'char_prompts'] + 'inventory', 'credential', 'char_prompts', 'fail_on_job_failure'] class WorkflowJobTemplateNode(WorkflowNodeBase): # TODO: Ensure the API forces workflow_job_template being set @@ -383,6 +403,9 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, JobNotificationMixin, Workflow from awx.main.tasks import RunWorkflowJob return RunWorkflowJob + def _has_failed(self): + return self.workflow_job_nodes.filter(job__status='failed', fail_on_job_failure=True).exists() + def socketio_emit_data(self): return {} diff --git a/awx/main/scheduler/__init__.py b/awx/main/scheduler/__init__.py index b93ce6956e..6ecdc09b37 100644 --- a/awx/main/scheduler/__init__.py +++ b/awx/main/scheduler/__init__.py @@ -73,10 +73,12 @@ def process_finished_workflow_jobs(workflow_jobs): dag = WorkflowDAG(workflow_job) if dag.is_workflow_done(): with transaction.atomic(): - # TODO: detect if wfj failed - workflow_job.status = 'completed' + if workflow_job._has_failed(): + workflow_job.status = 'failed' + else: + workflow_job.status = 'successful' workflow_job.save() - workflow_job.websocket_emit_status('completed') + workflow_job.websocket_emit_status(workflow_job.status) def rebuild_graph(): """Regenerate the task graph by refreshing known tasks from Tower, purging diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index cc61c34ed9..aa622d996a 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -90,3 +90,42 @@ class TestWorkflowJobTemplate: assert len(parent_qs) == 1 assert parent_qs[0] == wfjt.workflow_job_template_nodes.all()[1] +@pytest.mark.django_db +class TestWorkflowJobFailure: + @pytest.fixture + def wfj(self): + return WorkflowJob.objects.create(name='test-wf-job') + + def test_workflow_has_failed(self, wfj): + """ + Test that a single failed node with fail_on_job_failure = true + leads to the entire WF being marked as failed + """ + job = Job.objects.create(name='test-job', status='failed') + # Node has a failed job connected + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) + assert wfj._has_failed() + + def test_workflow_not_failed_unran_job(self, wfj): + """ + Test that an un-ran node will not mark workflow job as failed + """ + WorkflowJobNode.objects.create(workflow_job=wfj) + assert not wfj._has_failed() + + def test_workflow_not_failed_successful_job(self, wfj): + """ + Test that a sucessful node will not mark workflow job as failed + """ + job = Job.objects.create(name='test-job', status='successful') + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) + assert not wfj._has_failed() + + def test_workflow_not_failed_failed_job_but_okay(self, wfj): + """ + Test that a failed node will not mark workflow job as failed + if the fail_on_job_failure is set to false + """ + job = Job.objects.create(name='test-job', status='failed') + WorkflowJobNode.objects.create(workflow_job=wfj, job=job, fail_on_job_failure=False) + assert not wfj._has_failed() diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index 9df61ffe97..bc7e9b5bce 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -139,6 +139,7 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_no_prompts.char_prompts, inventory=None, credential=None, unified_job_template=wfjt_node_no_prompts.unified_job_template, + fail_on_job_failure=True, workflow_job=workflow_job_unit) def test_create_with_prompts(self, wfjt_node_with_prompts, workflow_job_unit, mocker): @@ -150,6 +151,7 @@ class TestWorkflowJobCreate: inventory=wfjt_node_with_prompts.inventory, credential=wfjt_node_with_prompts.credential, unified_job_template=wfjt_node_with_prompts.unified_job_template, + fail_on_job_failure=True, workflow_job=workflow_job_unit) @mock.patch('awx.main.models.workflow.WorkflowNodeBase.get_parent_nodes', lambda self: []) @@ -215,7 +217,7 @@ class TestWorkflowWarnings: def test_warn_scan_errors_node_prompts(self, job_node_with_prompts): job_node_with_prompts.unified_job_template.job_type = 'scan' - job_node_with_prompts.job_type = 'run' + job_node_with_prompts.char_prompts['job_type'] = 'run' job_node_with_prompts.inventory = Inventory(name='different-inventory', pk=23) assert 'ignored' in job_node_with_prompts.get_prompts_warnings() assert 'job_type' in job_node_with_prompts.get_prompts_warnings()['ignored'] From 6e228248c1d47651a5efa0b41ef2f21bab129b44 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 27 Oct 2016 12:58:27 -0400 Subject: [PATCH 05/14] remove fail_on_job_failure from the workflow status PR --- awx/api/serializers.py | 6 +---- ...rkflow_job_template_workflow_nodes_list.md | 7 +++--- .../0045_v310_workflow_failure_condition.py | 24 ------------------- awx/main/models/workflow.py | 8 ++----- .../tests/functional/models/test_workflow.py | 17 ++++--------- .../tests/unit/models/test_workflow_unit.py | 2 -- 6 files changed, 12 insertions(+), 52 deletions(-) delete mode 100644 awx/main/migrations/0045_v310_workflow_failure_condition.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d681fac426..ad84b9e421 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2249,15 +2249,11 @@ class WorkflowNodeBaseSerializer(BaseSerializer): success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) - fail_on_job_failure = serializers.BooleanField( - help_text=('If set to true, and if the job runs and fails, ' - 'the workflow is marked as failed.'), - default=True) class Meta: fields = ('*', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', - 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags', 'fail_on_job_failure') + 'inventory', 'credential', 'job_type', 'job_tags', 'skip_tags', 'limit', 'skip_tags') def get_related(self, obj): res = super(WorkflowNodeBaseSerializer, self).get_related(obj) diff --git a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md index e6b078a23f..9e5d0f688f 100644 --- a/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md +++ b/awx/api/templates/api/workflow_job_template_workflow_nodes_list.md @@ -7,8 +7,9 @@ the subsequent actions are to: - run nodes contained in "failure_nodes" or "always_nodes" if job failed - run nodes contained in "success_nodes" or "always_nodes" if job succeeded -The workflow is marked as failed if any jobs run as part of that workflow fail -and have the field `fail_on_job_failure` set to true. If not, the workflow -job is marked as successful. +The workflow job is marked as `successful` if all of the jobs running as +a part of the workflow job have completed, and the workflow job has not +been canceled. Even if a job within the workflow has failed, the workflow +job will not be marked as failed. {% include "api/sub_list_create_api_view.md" %} \ No newline at end of file diff --git a/awx/main/migrations/0045_v310_workflow_failure_condition.py b/awx/main/migrations/0045_v310_workflow_failure_condition.py deleted file mode 100644 index 9bafa0feb6..0000000000 --- a/awx/main/migrations/0045_v310_workflow_failure_condition.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0044_v310_project_playbook_files'), - ] - - operations = [ - migrations.AddField( - model_name='workflowjobnode', - name='fail_on_job_failure', - field=models.BooleanField(default=True), - ), - migrations.AddField( - model_name='workflowjobtemplatenode', - name='fail_on_job_failure', - field=models.BooleanField(default=True), - ), - ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 3bc0608cfc..50739829fe 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -60,10 +60,6 @@ class WorkflowNodeBase(CreatedModifiedModel): default=None, on_delete=models.SET_NULL, ) - fail_on_job_failure = models.BooleanField( - blank=True, - default=True, - ) # Prompting-related fields inventory = models.ForeignKey( 'Inventory', @@ -157,7 +153,7 @@ class WorkflowNodeBase(CreatedModifiedModel): Return field names that should be copied from template node to job node. ''' return ['workflow_job', 'unified_job_template', - 'inventory', 'credential', 'char_prompts', 'fail_on_job_failure'] + 'inventory', 'credential', 'char_prompts'] class WorkflowJobTemplateNode(WorkflowNodeBase): # TODO: Ensure the API forces workflow_job_template being set @@ -404,7 +400,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, JobNotificationMixin, Workflow return RunWorkflowJob def _has_failed(self): - return self.workflow_job_nodes.filter(job__status='failed', fail_on_job_failure=True).exists() + return False def socketio_emit_data(self): return {} diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index aa622d996a..0c3d5d88be 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -92,20 +92,14 @@ class TestWorkflowJobTemplate: @pytest.mark.django_db class TestWorkflowJobFailure: + """ + Tests to re-implement if workflow failure status is introduced in + a future Tower version. + """ @pytest.fixture def wfj(self): return WorkflowJob.objects.create(name='test-wf-job') - def test_workflow_has_failed(self, wfj): - """ - Test that a single failed node with fail_on_job_failure = true - leads to the entire WF being marked as failed - """ - job = Job.objects.create(name='test-job', status='failed') - # Node has a failed job connected - WorkflowJobNode.objects.create(workflow_job=wfj, job=job) - assert wfj._has_failed() - def test_workflow_not_failed_unran_job(self, wfj): """ Test that an un-ran node will not mark workflow job as failed @@ -124,8 +118,7 @@ class TestWorkflowJobFailure: def test_workflow_not_failed_failed_job_but_okay(self, wfj): """ Test that a failed node will not mark workflow job as failed - if the fail_on_job_failure is set to false """ job = Job.objects.create(name='test-job', status='failed') - WorkflowJobNode.objects.create(workflow_job=wfj, job=job, fail_on_job_failure=False) + WorkflowJobNode.objects.create(workflow_job=wfj, job=job) assert not wfj._has_failed() diff --git a/awx/main/tests/unit/models/test_workflow_unit.py b/awx/main/tests/unit/models/test_workflow_unit.py index bc7e9b5bce..add8379727 100644 --- a/awx/main/tests/unit/models/test_workflow_unit.py +++ b/awx/main/tests/unit/models/test_workflow_unit.py @@ -139,7 +139,6 @@ class TestWorkflowJobCreate: char_prompts=wfjt_node_no_prompts.char_prompts, inventory=None, credential=None, unified_job_template=wfjt_node_no_prompts.unified_job_template, - fail_on_job_failure=True, workflow_job=workflow_job_unit) def test_create_with_prompts(self, wfjt_node_with_prompts, workflow_job_unit, mocker): @@ -151,7 +150,6 @@ class TestWorkflowJobCreate: inventory=wfjt_node_with_prompts.inventory, credential=wfjt_node_with_prompts.credential, unified_job_template=wfjt_node_with_prompts.unified_job_template, - fail_on_job_failure=True, workflow_job=workflow_job_unit) @mock.patch('awx.main.models.workflow.WorkflowNodeBase.get_parent_nodes', lambda self: []) From defd271c90943ac5b1e3cd874b52b1fc8ceb21e6 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Fri, 28 Oct 2016 14:21:19 -0400 Subject: [PATCH 06/14] Make sure we bootstrap the static dir prior to starting --- tools/docker-compose/start_development.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/docker-compose/start_development.sh b/tools/docker-compose/start_development.sh index e2db66c3e4..688eeded33 100755 --- a/tools/docker-compose/start_development.sh +++ b/tools/docker-compose/start_development.sh @@ -40,5 +40,7 @@ make version_file make migrate make init +mkdir -p /tower_devel/awx/public/static + # Start the service make honcho From a49095bdbca6481beef9c60b25a6e3bc87ae347d Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Fri, 28 Oct 2016 14:28:06 -0400 Subject: [PATCH 07/14] Boolean / Smart Search (#3631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Part 1: building new search components Directives: smart-search, column-sort, paginate Service: QuerySet Model: DjangoSearchModel * Part 2: Implementing new search components, de-implementing old search components Remove old code: * tagSearch directive * old pagination strategy * old column sorting strategy * lookup Add new directives to list/form generator: * smart-search, * paginate * column-sort Connect $state + dataset resolution * upgrade ui-router lib to v1.0.0-beta3 * Custom $urlMatcherFactory.type - queryset * Render lists, forms, related, lookups in named views * Provide html templates in list/form/lookup/related state definitions * Provide dataset through resolve block in state definitions Update utilities * isEmpty filter * use async validation strategy in awlookup directive * Part 3: State implementations (might split into per-module commits) * Support optional state definition flag: squashSearchUrl. *_search params are only URI-encoded if squashSearchUrl is falsey. * * Fix list badge counts * Clear search input after search term(s) applied * Chain of multiple search terms in one submission * Hook up activity stream * Hook up portal mode * Fix pagination range calculations * Hook up organization sub-list views * Hook up listDefinition.search defaults * Fix ng-disabled conditions reflecting RBAC access on form fields * Fix actively-editing indicator in generated lists * form generator - fix undefined span, remove dead event listeners * wrap hosts/groups lists in a panel, fix groups list error * Smart search directive: clear all search tags * Search tags - ‘Clear All’ text - 12px Search key - remove top padding/margin Search key - reverse bolding of relationship fields / label, add commas Search tags - remove padding-bottom Lookup modal - “X” close button styled incorrectly Lookup modal - List title not rendered Lookup modal - 20px margin between buttons * Portal Mode Fix default column-sort on jobs list Hide column-oort on job status column Apply custom search bar sizes * stateDefinition.factory Return ES6 Promise instead of $q promise. $q cannot be safely provided during module.config() phase Some generated state trees (inventory / inventoryManage) need to be reduced to one promise. Side-step issues caused by ui-router de-registering ALL registered states that match placeholder state name/url pattern. e.g. inventories.lazyLoad() would de-register inventoryManage states if a page refresh occured @ /#/inventories/** * Combine generated state trees: inventories + inventoryManage Hook up inventory sync schedule list/form add /form edit views * Hook up system job schedule list/add/edit states * Fix breadcrumb of generated states in /setup view Fix typo in scheduler search prefix * Remove old search system deritus from list definitions * Fix breadcrumb definitions in states registered in app.js config block * Transclude list action buttons in generated form lists * Lookup Modal passes acceptance criterea: Modal cancel/exit - don’t update form field’s ng-model Modal save - do update form field's ng-model Transclude generated list contents into directive Lookup modal test spec * Fix typo in merge conflict resolution * Disable failing unit tests pending revision * Integrate smart-search architechture into add-permissions modal * use a semicolon delimiter instead of comma to avoid collision with django __in comparator * Hook up Dashboard > Hosts states, update Dashboard Inventory/Project counts with new search filters * Misc bug splat Add 20px spacing around root ui-view Fix missing closing div in related views Remove dupe line in smart-search controller * Remove defunct LookupHelper code * Rebuild inventories list status tooltips on updates to dataset Code cleanup - remove defunct modules Remove LookupHelper / LookupInit code Remove pre-RBAC permissions module * Add mising stateTree / basePath properties to form definitions * Resolve i18n conflicts in list and form generator Freeze dependencies * Integrate sockets * Final bug splat: fix jobs > job details and jobs > scheduled routing fix mis-resolved merge conflicts swap console.info for $log.debug --- awx/ui/client/legacy-styles/ansible-ui.less | 61 +- awx/ui/client/legacy-styles/jobs.less | 6 - awx/ui/client/legacy-styles/lists.less | 38 - .../addPermissions.controller.js | 133 +- .../addPermissions.directive.js | 73 +- .../addPermissions.partial.html | 10 +- .../addPermissionsList.directive.js | 80 +- .../permissionsUsers.list.js | 8 +- .../activitystream.controller.js | 35 +- .../activitystream.partial.html | 3 - .../activity-stream/activitystream.route.js | 89 +- .../stream-dropdown-nav.directive.js | 11 +- awx/ui/client/src/app.js | 501 ++--- .../src/bread-crumb/bread-crumb.directive.js | 18 +- awx/ui/client/src/controllers/Credentials.js | 533 ++--- awx/ui/client/src/controllers/JobEvents.js | 82 +- awx/ui/client/src/controllers/JobHosts.js | 53 +- awx/ui/client/src/controllers/Jobs.js | 209 +- awx/ui/client/src/controllers/Projects.js | 551 ++--- awx/ui/client/src/controllers/Schedules.js | 53 +- awx/ui/client/src/controllers/Teams.js | 324 +-- awx/ui/client/src/controllers/Users.js | 448 ++-- .../counts/dashboard-counts.directive.js | 6 +- .../hosts/dashboard-hosts-edit.partial.html | 4 - .../hosts/dashboard-hosts-list.controller.js | 130 +- .../hosts/dashboard-hosts-list.partial.html | 4 - .../dashboard/hosts/dashboard-hosts.form.js | 6 +- .../dashboard/hosts/dashboard-hosts.list.js | 19 - .../dashboard/hosts/dashboard-hosts.route.js | 61 - awx/ui/client/src/dashboard/hosts/main.js | 38 +- awx/ui/client/src/forms/Credentials.js | 102 +- awx/ui/client/src/forms/Groups.js | 89 +- awx/ui/client/src/forms/HostGroups.js | 3 +- awx/ui/client/src/forms/Hosts.js | 26 +- awx/ui/client/src/forms/Inventories.js | 213 +- awx/ui/client/src/forms/JobTemplates.js | 129 +- awx/ui/client/src/forms/JobVarsPrompt.js | 6 +- awx/ui/client/src/forms/Jobs.js | 1 + awx/ui/client/src/forms/Organizations.js | 27 +- awx/ui/client/src/forms/Projects.js | 92 +- awx/ui/client/src/forms/Teams.js | 53 +- awx/ui/client/src/forms/Users.js | 87 +- awx/ui/client/src/helpers.js | 10 - awx/ui/client/src/helpers/Adhoc.js | 2 +- awx/ui/client/src/helpers/Credentials.js | 17 +- awx/ui/client/src/helpers/Groups.js | 39 +- awx/ui/client/src/helpers/Hosts.js | 111 +- awx/ui/client/src/helpers/JobDetail.js | 154 +- awx/ui/client/src/helpers/JobSubmission.js | 2 +- awx/ui/client/src/helpers/JobTemplates.js | 71 +- awx/ui/client/src/helpers/Jobs.js | 170 +- .../client/src/helpers/PaginationHelpers.js | 182 -- awx/ui/client/src/helpers/Schedules.js | 232 +-- awx/ui/client/src/helpers/inventory.js | 6 +- awx/ui/client/src/helpers/refresh-related.js | 59 - awx/ui/client/src/helpers/refresh.js | 104 - awx/ui/client/src/helpers/related-search.js | 295 --- awx/ui/client/src/helpers/search.js | 536 ----- awx/ui/client/src/helpers/teams.js | 42 +- .../add/inventory-add.controller.js | 73 +- .../inventories/add/inventory-add.route.js | 19 - awx/ui/client/src/inventories/add/main.js | 8 +- .../edit/inventory-edit.controller.js | 333 +-- .../inventories/edit/inventory-edit.route.js | 22 - awx/ui/client/src/inventories/edit/main.js | 6 +- .../src/inventories/inventories.partial.html | 5 - .../list/inventory-list.controller.js | 187 +- .../inventories/list/inventory-list.route.js | 22 - awx/ui/client/src/inventories/list/main.js | 8 +- awx/ui/client/src/inventories/main.js | 219 +- .../manage/adhoc/adhoc.controller.js | 15 +- .../inventories/manage/adhoc/adhoc.form.js | 13 +- .../inventories/manage/adhoc/adhoc.route.js | 7 +- .../src/inventories/manage/adhoc/main.js | 6 +- .../copy-move/copy-move-groups.controller.js | 32 +- .../copy-move/copy-move-hosts.controller.js | 32 +- .../manage/copy-move/copy-move.route.js | 20 +- .../src/inventories/manage/copy-move/main.js | 9 +- .../manage/groups/groups-add.controller.js | 147 +- .../manage/groups/groups-edit.controller.js | 250 +-- .../manage/groups/groups-form.partial.html | 4 - .../manage/groups/groups-list.controller.js | 120 +- .../manage/groups/groups-list.partial.html | 1 - .../inventories/manage/groups/groups.route.js | 61 - .../src/inventories/manage/groups/main.js | 11 +- .../manage/hosts/hosts-add.controller.js | 65 +- .../manage/hosts/hosts-edit.controller.js | 42 +- .../manage/hosts/hosts-form.partial.html | 4 - .../manage/hosts/hosts-list.controller.js | 69 +- .../inventories/manage/hosts/hosts.route.js | 60 - .../src/inventories/manage/hosts/main.js | 57 +- .../manage/inventory-manage.partial.html | 11 +- .../manage/inventory-manage.route.js | 104 +- .../manage/inventory-manage.service.js | 16 +- awx/ui/client/src/inventories/manage/main.js | 8 +- .../inventory-scripts/add/add.controller.js | 94 +- .../inventory-scripts/add/add.partial.html | 3 - .../src/inventory-scripts/add/add.route.js | 18 - .../client/src/inventory-scripts/add/main.js | 6 +- .../inventory-scripts/edit/edit.controller.js | 138 +- .../inventory-scripts/edit/edit.partial.html | 3 - .../src/inventory-scripts/edit/edit.route.js | 49 - .../client/src/inventory-scripts/edit/main.js | 6 +- .../inventory-scripts.form.js | 35 +- .../inventory-scripts/list/list.controller.js | 148 +- .../inventory-scripts/list/list.partial.html | 4 - .../src/inventory-scripts/list/list.route.js | 22 - .../client/src/inventory-scripts/list/main.js | 10 +- awx/ui/client/src/inventory-scripts/main.js | 64 +- .../job-detail/host-event/host-event.route.js | 129 +- .../host-events/host-events.block.less | 11 +- .../host-events/host-events.controller.js | 90 +- .../host-events/host-events.partial.html | 7 +- .../host-events/host-events.route.js | 6 - .../host-summary/host-summary.controller.js | 43 +- .../host-summary/host-summary.route.js | 6 - .../src/job-detail/job-detail.controller.js | 248 ++- .../client/src/job-detail/job-detail.route.js | 14 +- .../job-submission.controller.js | 72 +- .../add/inventory-job-templates-add.route.js | 17 - .../add/job-templates-add.controller.js | 151 +- .../add/job-templates-add.partial.html | 5 - .../add/job-templates-add.route.js | 32 - awx/ui/client/src/job-templates/add/main.js | 8 +- .../inventory-job-templates-edit.route.js | 17 - .../edit/job-templates-edit.controller.js | 155 +- .../edit/job-templates-edit.partial.html | 5 - .../edit/job-templates-edit.route.js | 34 - awx/ui/client/src/job-templates/edit/main.js | 8 +- .../labels/labelsList.directive.js | 3 +- .../list/job-templates-list.controller.js | 169 +- .../list/job-templates-list.partial.html | 6 - .../list/job-templates-list.route.js | 26 - awx/ui/client/src/job-templates/list/main.js | 6 +- awx/ui/client/src/job-templates/main.js | 38 +- .../shared/question-definition.form.js | 39 +- awx/ui/client/src/lists/AllJobs.js | 48 +- awx/ui/client/src/lists/CompletedJobs.js | 27 +- awx/ui/client/src/lists/Credentials.js | 3 - awx/ui/client/src/lists/Inventories.js | 28 +- awx/ui/client/src/lists/InventoryGroups.js | 63 +- awx/ui/client/src/lists/InventoryHosts.js | 20 - awx/ui/client/src/lists/JobEvents.js | 14 - awx/ui/client/src/lists/JobHosts.js | 29 +- awx/ui/client/src/lists/JobTemplates.js | 6 +- awx/ui/client/src/lists/Jobs.js | 20 +- awx/ui/client/src/lists/PortalJobTemplates.js | 1 - awx/ui/client/src/lists/PortalJobs.js | 9 +- awx/ui/client/src/lists/Projects.js | 11 +- awx/ui/client/src/lists/ScheduledJobs.js | 17 +- awx/ui/client/src/lists/Schedules.js | 6 +- awx/ui/client/src/lists/Streams.js | 17 +- awx/ui/client/src/lists/Users.js | 11 +- .../src/login/loginBackDrop.partial.html | 2 +- awx/ui/client/src/lookup/lookup.block.less | 26 - awx/ui/client/src/lookup/lookup.factory.js | 307 --- awx/ui/client/src/lookup/main.js | 12 - .../management-jobs/card/card.controller.js | 23 +- .../notifications/notification.controller.js | 31 +- .../src/management-jobs/scheduler/main.js | 101 +- .../src/notifications/add/add.controller.js | 307 ++- .../src/notifications/add/add.partial.html | 3 - .../client/src/notifications/add/add.route.js | 18 - awx/ui/client/src/notifications/add/main.js | 6 +- .../src/notifications/edit/edit.controller.js | 456 ++-- .../src/notifications/edit/edit.partial.html | 3 - .../src/notifications/edit/edit.route.js | 48 - awx/ui/client/src/notifications/edit/main.js | 6 +- awx/ui/client/src/notifications/main.js | 84 +- .../list.controller.js | 358 ++-- .../list.partial.html | 4 - .../notification-templates-list/list.route.js | 18 - .../notification-templates-list/main.js | 6 +- .../notificationTemplates.form.js | 86 +- .../shared/toggle-notification.factory.js | 3 +- awx/ui/client/src/organizations/add/main.js | 13 - .../add/organizations-add.controller.js | 35 +- .../add/organizations-add.partial.html | 4 - .../add/organizations-add.route.js | 19 - awx/ui/client/src/organizations/edit/main.js | 15 - .../edit/organizations-edit.controller.js | 133 +- .../edit/organizations-edit.route.js | 24 - .../linkout/addUsers/addUsers.controller.js | 47 +- .../linkout/addUsers/addUsers.directive.js | 7 +- .../linkout/addUsers/addUsers.partial.html | 6 +- .../organizations-admins.controller.js | 127 +- .../organizations-inventories.controller.js | 487 ++--- .../organizations-job-templates.controller.js | 94 +- .../organizations-projects.controller.js | 577 +++--- .../organizations-teams.controller.js | 116 +- .../organizations-users.controller.js | 142 +- .../client/src/organizations/linkout/main.js | 7 +- .../organizations-linkout.partial.html | 4 - .../linkout/organizations-linkout.route.js | 530 +++-- awx/ui/client/src/organizations/list/main.js | 13 - .../list/organizations-list.controller.js | 143 +- .../list/organizations-list.partial.html | 47 +- .../list/organizations-list.route.js | 26 - awx/ui/client/src/organizations/main.js | 64 +- awx/ui/client/src/partials/jobs.html | 34 +- awx/ui/client/src/partials/projects.html | 5 - .../src/permissions/add/add.controller.js | 157 -- awx/ui/client/src/permissions/add/main.js | 17 - .../src/permissions/add/team-add.route.js | 14 - .../src/permissions/add/user-add.route.js | 14 - .../src/permissions/edit/edit.controller.js | 184 -- awx/ui/client/src/permissions/edit/main.js | 17 - .../src/permissions/edit/team-edit.route.js | 14 - .../src/permissions/edit/user-edit.route.js | 14 - .../src/permissions/list/list.controller.js | 143 -- awx/ui/client/src/permissions/list/main.js | 17 - .../src/permissions/list/team-list.route.js | 14 - .../src/permissions/list/user-list.route.js | 14 - awx/ui/client/src/permissions/main.js | 26 - .../shared/category-change.factory.js | 74 - .../shared/get-search-select.factory.js | 29 - .../permissions/shared/permissions.form.js | 147 -- .../permissions/shared/permissions.list.js | 75 - .../shared/team-permissions.partial.html | 3 - .../shared/user-permissions.partial.html | 3 - .../portal-mode-job-templates.controller.js | 50 +- .../portal-mode-job-templates.partial.html | 4 - .../portal-mode-jobs.controller.js | 83 +- .../portal-mode-layout.partial.html | 28 +- .../src/portal-mode/portal-mode.route.js | 94 +- .../client/src/rest/restServices.factory.js | 13 +- awx/ui/client/src/scheduler/main.js | 138 +- .../src/scheduler/scheduler.controller.js | 138 -- .../src/scheduler/schedulerAdd.controller.js | 9 +- .../src/scheduler/schedulerEdit.controller.js | 6 +- .../src/scheduler/schedulerList.controller.js | 123 ++ .../src/search/getSearchHtml.service.js | 51 - awx/ui/client/src/search/main.js | 9 - awx/ui/client/src/search/tagSearch.block.less | 254 --- .../client/src/search/tagSearch.controller.js | 132 -- .../client/src/search/tagSearch.directive.js | 32 - .../client/src/search/tagSearch.partial.html | 79 - awx/ui/client/src/search/tagSearch.service.js | 226 -- awx/ui/client/src/shared/Utilities.js | 5 +- .../column-sort/column-sort.controller.js | 54 + .../column-sort/column-sort.directive.js | 19 + .../column-sort/column-sort.partial.html | 4 + awx/ui/client/src/shared/column-sort/main.js | 7 + awx/ui/client/src/shared/directives.js | 1839 ++++++++--------- awx/ui/client/src/shared/filters.js | 13 + awx/ui/client/src/shared/form-generator.js | 712 ++----- awx/ui/client/src/shared/generator-helpers.js | 166 +- .../list-generator/list-generator.factory.js | 925 ++++----- .../src/shared/lookup/lookup-modal.block.less | 6 + .../shared/lookup/lookup-modal.directive.js | 34 + .../shared/lookup/lookup-modal.partial.html | 24 + awx/ui/client/src/shared/lookup/main.js | 11 + awx/ui/client/src/shared/main.js | 24 +- .../select-list-item.directive.js | 2 +- awx/ui/client/src/shared/paginate/main.js | 13 + .../src/shared/paginate/paginate.block.less | 53 + .../shared/paginate/paginate.controller.js | 66 + .../src/shared/paginate/paginate.directive.js | 16 + .../src/shared/paginate/paginate.partial.html | 44 + awx/ui/client/src/shared/pagination/main.js | 11 - .../shared/pagination/pagination.service.js | 44 - .../smart-search/django-search-model.class.js | 56 + awx/ui/client/src/shared/smart-search/main.js | 12 + .../shared/smart-search/queryset.service.js | 116 ++ .../smart-search/smart-search.block.less | 234 +++ .../smart-search/smart-search.controller.js | 113 + .../smart-search/smart-search.directive.js | 23 + .../smart-search/smart-search.partial.html | 57 + .../src/shared/socket/socket.service.js | 21 +- .../src/shared/stateDefinitions.factory.js | 434 ++++ .../src/shared/stateExtender.provider.js | 51 +- .../adhoc/standard-out-adhoc.route.js | 16 +- .../standard-out-management-jobs.route.js | 14 +- .../standard-out-scm-update.route.js | 14 +- awx/ui/client/src/widgets/Stream.js | 62 +- awx/ui/karma.conf.js | 2 + awx/ui/npm-shrinkwrap.json | 16 +- awx/ui/package.json | 2 +- awx/ui/templates/ui/index.html | 310 ++- .../lookup/lookup-modal.directive-test.js | 131 ++ .../spec/paginate/paginate.directive-test.js | 116 ++ .../smart-search/queryset.service-test.js | 85 + .../smart-search.directive-test.js | 163 ++ 283 files changed, 9625 insertions(+), 14375 deletions(-) delete mode 100644 awx/ui/client/src/activity-stream/activitystream.partial.html delete mode 100644 awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.partial.html delete mode 100644 awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.partial.html delete mode 100644 awx/ui/client/src/dashboard/hosts/dashboard-hosts.route.js delete mode 100644 awx/ui/client/src/helpers/PaginationHelpers.js delete mode 100644 awx/ui/client/src/helpers/refresh-related.js delete mode 100644 awx/ui/client/src/helpers/refresh.js delete mode 100644 awx/ui/client/src/helpers/related-search.js delete mode 100644 awx/ui/client/src/helpers/search.js delete mode 100644 awx/ui/client/src/inventories/add/inventory-add.route.js delete mode 100644 awx/ui/client/src/inventories/edit/inventory-edit.route.js delete mode 100644 awx/ui/client/src/inventories/inventories.partial.html delete mode 100644 awx/ui/client/src/inventories/list/inventory-list.route.js delete mode 100644 awx/ui/client/src/inventories/manage/groups/groups-form.partial.html delete mode 100644 awx/ui/client/src/inventories/manage/groups/groups.route.js delete mode 100644 awx/ui/client/src/inventories/manage/hosts/hosts-form.partial.html delete mode 100644 awx/ui/client/src/inventories/manage/hosts/hosts.route.js delete mode 100644 awx/ui/client/src/inventory-scripts/add/add.partial.html delete mode 100644 awx/ui/client/src/inventory-scripts/add/add.route.js delete mode 100644 awx/ui/client/src/inventory-scripts/edit/edit.partial.html delete mode 100644 awx/ui/client/src/inventory-scripts/edit/edit.route.js delete mode 100644 awx/ui/client/src/inventory-scripts/list/list.partial.html delete mode 100644 awx/ui/client/src/inventory-scripts/list/list.route.js delete mode 100644 awx/ui/client/src/job-templates/add/inventory-job-templates-add.route.js delete mode 100644 awx/ui/client/src/job-templates/add/job-templates-add.partial.html delete mode 100644 awx/ui/client/src/job-templates/add/job-templates-add.route.js delete mode 100644 awx/ui/client/src/job-templates/edit/inventory-job-templates-edit.route.js delete mode 100644 awx/ui/client/src/job-templates/edit/job-templates-edit.partial.html delete mode 100644 awx/ui/client/src/job-templates/edit/job-templates-edit.route.js delete mode 100644 awx/ui/client/src/job-templates/list/job-templates-list.partial.html delete mode 100644 awx/ui/client/src/job-templates/list/job-templates-list.route.js delete mode 100644 awx/ui/client/src/lookup/lookup.block.less delete mode 100644 awx/ui/client/src/lookup/lookup.factory.js delete mode 100644 awx/ui/client/src/lookup/main.js delete mode 100644 awx/ui/client/src/notifications/add/add.partial.html delete mode 100644 awx/ui/client/src/notifications/add/add.route.js delete mode 100644 awx/ui/client/src/notifications/edit/edit.partial.html delete mode 100644 awx/ui/client/src/notifications/edit/edit.route.js delete mode 100644 awx/ui/client/src/notifications/notification-templates-list/list.partial.html delete mode 100644 awx/ui/client/src/notifications/notification-templates-list/list.route.js delete mode 100644 awx/ui/client/src/organizations/add/main.js delete mode 100644 awx/ui/client/src/organizations/add/organizations-add.partial.html delete mode 100644 awx/ui/client/src/organizations/add/organizations-add.route.js delete mode 100644 awx/ui/client/src/organizations/edit/main.js delete mode 100644 awx/ui/client/src/organizations/edit/organizations-edit.route.js delete mode 100644 awx/ui/client/src/organizations/linkout/organizations-linkout.partial.html delete mode 100644 awx/ui/client/src/organizations/list/main.js delete mode 100644 awx/ui/client/src/organizations/list/organizations-list.route.js delete mode 100644 awx/ui/client/src/partials/projects.html delete mode 100644 awx/ui/client/src/permissions/add/add.controller.js delete mode 100644 awx/ui/client/src/permissions/add/main.js delete mode 100644 awx/ui/client/src/permissions/add/team-add.route.js delete mode 100644 awx/ui/client/src/permissions/add/user-add.route.js delete mode 100644 awx/ui/client/src/permissions/edit/edit.controller.js delete mode 100644 awx/ui/client/src/permissions/edit/main.js delete mode 100644 awx/ui/client/src/permissions/edit/team-edit.route.js delete mode 100644 awx/ui/client/src/permissions/edit/user-edit.route.js delete mode 100644 awx/ui/client/src/permissions/list/list.controller.js delete mode 100644 awx/ui/client/src/permissions/list/main.js delete mode 100644 awx/ui/client/src/permissions/list/team-list.route.js delete mode 100644 awx/ui/client/src/permissions/list/user-list.route.js delete mode 100644 awx/ui/client/src/permissions/main.js delete mode 100644 awx/ui/client/src/permissions/shared/category-change.factory.js delete mode 100644 awx/ui/client/src/permissions/shared/get-search-select.factory.js delete mode 100644 awx/ui/client/src/permissions/shared/permissions.form.js delete mode 100644 awx/ui/client/src/permissions/shared/permissions.list.js delete mode 100644 awx/ui/client/src/permissions/shared/team-permissions.partial.html delete mode 100644 awx/ui/client/src/permissions/shared/user-permissions.partial.html delete mode 100644 awx/ui/client/src/portal-mode/portal-mode-job-templates.partial.html delete mode 100644 awx/ui/client/src/scheduler/scheduler.controller.js create mode 100644 awx/ui/client/src/scheduler/schedulerList.controller.js delete mode 100644 awx/ui/client/src/search/getSearchHtml.service.js delete mode 100644 awx/ui/client/src/search/main.js delete mode 100644 awx/ui/client/src/search/tagSearch.block.less delete mode 100644 awx/ui/client/src/search/tagSearch.controller.js delete mode 100644 awx/ui/client/src/search/tagSearch.directive.js delete mode 100644 awx/ui/client/src/search/tagSearch.partial.html delete mode 100644 awx/ui/client/src/search/tagSearch.service.js create mode 100644 awx/ui/client/src/shared/column-sort/column-sort.controller.js create mode 100644 awx/ui/client/src/shared/column-sort/column-sort.directive.js create mode 100644 awx/ui/client/src/shared/column-sort/column-sort.partial.html create mode 100644 awx/ui/client/src/shared/column-sort/main.js create mode 100644 awx/ui/client/src/shared/lookup/lookup-modal.block.less create mode 100644 awx/ui/client/src/shared/lookup/lookup-modal.directive.js create mode 100644 awx/ui/client/src/shared/lookup/lookup-modal.partial.html create mode 100644 awx/ui/client/src/shared/lookup/main.js create mode 100644 awx/ui/client/src/shared/paginate/main.js create mode 100644 awx/ui/client/src/shared/paginate/paginate.block.less create mode 100644 awx/ui/client/src/shared/paginate/paginate.controller.js create mode 100644 awx/ui/client/src/shared/paginate/paginate.directive.js create mode 100644 awx/ui/client/src/shared/paginate/paginate.partial.html delete mode 100644 awx/ui/client/src/shared/pagination/main.js delete mode 100644 awx/ui/client/src/shared/pagination/pagination.service.js create mode 100644 awx/ui/client/src/shared/smart-search/django-search-model.class.js create mode 100644 awx/ui/client/src/shared/smart-search/main.js create mode 100644 awx/ui/client/src/shared/smart-search/queryset.service.js create mode 100644 awx/ui/client/src/shared/smart-search/smart-search.block.less create mode 100644 awx/ui/client/src/shared/smart-search/smart-search.controller.js create mode 100644 awx/ui/client/src/shared/smart-search/smart-search.directive.js create mode 100644 awx/ui/client/src/shared/smart-search/smart-search.partial.html create mode 100644 awx/ui/client/src/shared/stateDefinitions.factory.js create mode 100644 awx/ui/tests/spec/lookup/lookup-modal.directive-test.js create mode 100644 awx/ui/tests/spec/paginate/paginate.directive-test.js create mode 100644 awx/ui/tests/spec/smart-search/queryset.service-test.js create mode 100644 awx/ui/tests/spec/smart-search/smart-search.directive-test.js diff --git a/awx/ui/client/legacy-styles/ansible-ui.less b/awx/ui/client/legacy-styles/ansible-ui.less index dfa53e0d25..d554ce8cc0 100644 --- a/awx/ui/client/legacy-styles/ansible-ui.less +++ b/awx/ui/client/legacy-styles/ansible-ui.less @@ -729,64 +729,7 @@ legend { .navigation { margin: 15px 0 15px 0; } - -.page-number { - display: inline-block; - padding: 0; - margin: 0; -} - -.page-number-small { - display: inline-block; - margin-left: 10px; - font-size: 11px; -} - -/* Pagination */ - .page-label { - font-size: 12px; - margin-top: 0; - text-align: right; - } - - .pagination { - margin-top: 0; - margin-bottom: 7px; - } - - .pagination>li>a, - .pagination>li>span { - border: 1px solid @grey-border; - padding: 3px 6px; - font-size: 10px; - } - - .pagination li { - a#next-page { - border-radius: 0 4px 4px 0; - } - - a#previous-page { - border-radius: 4px 0 0 4px; - } - } - .modal-body { - .pagination { - margin-top: 15px; - margin-bottom: 0; - } - .pagination > li > a { - border: none; - padding-top: 0; - padding-bottom: 0; - } - .pagination > .active > a { - background-color: @default-bg; - color: #428bca; - border-color: none; - border: 1px solid @default-link; - } .alert { padding: 0; border: none; @@ -1623,6 +1566,10 @@ a.btn-disabled:hover { /* Sort link styles */ +.list-header-noSort:hover.list-header:hover{ + cursor: default; +} + .list-header:hover { cursor: pointer; } diff --git a/awx/ui/client/legacy-styles/jobs.less b/awx/ui/client/legacy-styles/jobs.less index 0ae748d0b4..86e14926e1 100644 --- a/awx/ui/client/legacy-styles/jobs.less +++ b/awx/ui/client/legacy-styles/jobs.less @@ -19,13 +19,7 @@ } .job-list { - .pagination li { - } - .pagination li a { - font-size: 12px; - padding: 3px 6px; - } i[class*="icon-job-"] { font-size: 13px; } diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 098f936ab1..763694f6d2 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -116,44 +116,6 @@ table, tbody { margin-left: 15px; } -/* -- Pagination -- */ -.List-pagination { - margin-top: 20px; - font-size: 12px; - color: @list-pagin-text; - text-transform: uppercase; - height: 22px; - display: flex; -} - -.List-paginationPagerHolder { - display: flex; - flex: 1 0 auto; -} - -.List-paginationPager { - display: flex; -} - -.List-paginationPager--pageof { - line-height: 22px; - margin-left: 10px; -} - -.List-paginationPager--item { - border-color: @list-pagin-bord; -} - -.List-paginationPager--active { - border-color: @list-pagin-bord-act!important; - background-color: @list-pagin-bg-act!important; -} - -.List-paginationItemsOf { - display: flex; - justify-content: flex-end; -} - .List-header { display: flex; min-height: 34px; diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js index 75177cc308..d5774e7c79 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.controller.js +++ b/awx/ui/client/src/access/addPermissions/addPermissions.controller.js @@ -11,23 +11,12 @@ * Controller for handling permissions adding */ -export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'ProcessErrors', function (rootScope, scope, GetBasePath, Rest, $q, Wait, ProcessErrors) { - var manuallyUpdateChecklists = function(list, id, isSelected) { - var elemScope = angular - .element("#" + - list + "s_table #" + id + ".List-tableRow input") - .scope(); - if (elemScope) { - elemScope.isSelected = !!isSelected; - } - }; +export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'ProcessErrors', function(rootScope, scope, GetBasePath, Rest, $q, Wait, ProcessErrors) { scope.allSelected = []; // the object permissions are being added to - scope.object = scope[scope.$parent.list - .iterator + "_obj"]; - + scope.object = scope.resourceData.data; // array for all possible roles for the object scope.roles = Object .keys(scope.object.summary_fields.object_roles) @@ -36,7 +25,8 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr value: scope.object.summary_fields .object_roles[key].id, label: scope.object.summary_fields - .object_roles[key].name }; + .object_roles[key].name + }; }); // TODO: get working with api @@ -48,7 +38,8 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr name: scope.object.summary_fields .object_roles[key].name, description: scope.object.summary_fields - .object_roles[key].description }; + .object_roles[key].description + }; }); scope.showKeyPane = false; @@ -63,90 +54,44 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr scope.teamsSelected = !scope.usersSelected; }; - // manually handle selection/deselection of user/team checkboxes - scope.$on("selectedOrDeselected", function(e, val) { - val = val.value; - if (val.isSelected) { - // deselected, so remove from the allSelected list - scope.allSelected = scope.allSelected.filter(function(i) { - // return all but the object who has the id and type - // of the element to deselect - return (!(val.id === i.id && val.type === i.type)); - }); + // pop/push into unified collection of selected users & teams + scope.$on("selectedOrDeselected", function(e, value) { + let item = value.value; + + function buildName(user) { + return (user.first_name && + user.last_name) ? + user.first_name + " " + + user.last_name : + user.username; + } + + if (item.isSelected) { + if (item.type === 'user') { + item.name = buildName(item); + } + scope.allSelected.push(item); } else { - // selected, so add to the allSelected list - var getName = function(val) { - if (val.type === "user") { - return (val.first_name && - val.last_name) ? - val.first_name + " " + - val.last_name : - val.username; - } else { - return val.name; - } - }; - scope.allSelected.push({ - name: getName(val), - type: val.type, - roles: [], - id: val.id - }); + scope.allSelected = _.remove(scope.allSelected, { id: item.id }); } }); - // used to handle changes to the itemsSelected scope var on "next page", - // "sorting etc." - scope.$on("itemsSelected", function(e, inList) { - // compile a list of objects that needed to be checked in the lists - scope.updateLists = scope.allSelected.filter(function(inMemory) { - var notInList = true; - inList.forEach(function(val) { - // if the object is part of the allSelected list and is - // selected, - // you don't need to add it updateLists - if (inMemory.id === val.id && - inMemory.type === val.type) { - notInList = false; - } - }); - return notInList; - }); - }); - - // handle changes to the updatedLists by manually selected those values in - // the UI - scope.$watch("updateLists", function(toUpdate) { - (toUpdate || []).forEach(function(obj) { - manuallyUpdateChecklists(obj.type, obj.id, true); - }); - - delete scope.updateLists; - }); - - // remove selected user/team - scope.removeObject = function(obj) { - manuallyUpdateChecklists(obj.type, obj.id, false); - - scope.allSelected = scope.allSelected.filter(function(i) { - return (!(obj.id === i.id && obj.type === i.type)); - }); - }; - // update post url list scope.$watch("allSelected", function(val) { scope.posts = _ .flatten((val || []) - .map(function (owner) { - var url = GetBasePath(owner.type + "s") + owner.id + - "/roles/"; + .map(function(owner) { + var url = GetBasePath(owner.type + "s") + owner.id + + "/roles/"; - return (owner.roles || []) - .map(function (role) { - return {url: url, - id: role.value}; - }); - })); + return (owner.roles || []) + .map(function(role) { + return { + url: url, + id: role.value + }; + }); + })); }, true); // post roles to api @@ -156,22 +101,22 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr var requests = scope.posts .map(function(post) { Rest.setUrl(post.url); - return Rest.post({"id": post.id}); + return Rest.post({ "id": post.id }); }); $q.all(requests) - .then(function () { + .then(function() { Wait('stop'); rootScope.$broadcast("refreshList", "permission"); scope.closeModal(); - }, function (error) { + }, function(error) { Wait('stop'); rootScope.$broadcast("refreshList", "permission"); scope.closeModal(); ProcessErrors(null, error.data, error.status, null, { hdr: 'Error!', msg: 'Failed to post role(s): POST returned status' + - error.status + error.status }); }); }; diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.directive.js b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js index 57bb658788..284110b0ce 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.directive.js +++ b/awx/ui/client/src/access/addPermissions/addPermissions.directive.js @@ -6,55 +6,28 @@ import addPermissionsController from './addPermissions.controller'; /* jshint unused: vars */ -export default - [ 'templateUrl', - 'Wait', - function(templateUrl, Wait) { - return { - restrict: 'E', - scope: true, - controller: addPermissionsController, - templateUrl: templateUrl('access/addPermissions/addPermissions'), - link: function(scope, element, attrs, ctrl) { - scope.withoutTeamPermissions = attrs.withoutTeamPermissions; - scope.toggleFormTabs('users'); +export default ['templateUrl', '$state', + 'Wait', 'addPermissionsUsersList', 'addPermissionsTeamsList', + function(templateUrl, $state, Wait, usersList, teamsList) { + return { + restrict: 'E', + scope: { + usersDataset: '=', + teamsDataset: '=', + resourceData: '=', + }, + controller: addPermissionsController, + templateUrl: templateUrl('access/addPermissions/addPermissions'), + link: function(scope, element, attrs) { + scope.toggleFormTabs('users'); + $('#add-permissions-modal').modal('show'); - $("body").addClass("is-modalOpen"); + scope.closeModal = function() { + $state.go('^', null, {reload: true}); + }; - $("body").append(element); - - Wait('start'); - - - scope.$broadcast("linkLists"); - - setTimeout(function() { - $('#add-permissions-modal').modal("show"); - }, 200); - - $('.modal[aria-hidden=false]').each(function () { - if ($(this).attr('id') !== 'add-permissions-modal') { - $(this).modal('hide'); - } - }); - - scope.closeModal = function() { - $("body").removeClass("is-modalOpen"); - $('#add-permissions-modal').on('hidden.bs.modal', - function () { - $('.AddPermissions').remove(); - }); - $('#add-permissions-modal').modal('hide'); - }; - - scope.$on('closePermissionsModal', function() { - scope.closeModal(); - }); - - Wait('stop'); - - window.scrollTo(0,0); - } - }; - } - ]; + window.scrollTo(0, 0); + } + }; + } +]; diff --git a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html index cc2f7ee0c7..264a4cf834 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissions.partial.html +++ b/awx/ui/client/src/access/addPermissions/addPermissions.partial.html @@ -45,13 +45,11 @@ -
- - +
+
-
- - +
+
", - link: function(scope, element, attrs, ctrl) { - scope.$on("linkLists", function(e) { - var generator = generateList, - list = addPermissionsTeamsList, - url = GetBasePath("teams"), - set = "teams", - id = "addPermissionsTeamsList", - mode = "edit"; +export default ['addPermissionsTeamsList', 'addPermissionsUsersList', '$compile', 'generateList', 'GetBasePath', 'SelectionInit', function(addPermissionsTeamsList, + addPermissionsUsersList, $compile, generateList, + GetBasePath, SelectionInit) { + return { + restrict: 'E', + scope: { + allSelected: '=', + view: '@', + dataset: '=' + }, + template: "
", + link: function(scope, element, attrs, ctrl) { + let listMap, list, list_html; - if (attrs.type === 'users') { - list = addPermissionsUsersList; - url = GetBasePath("users") + "?is_superuser=false"; - set = "users"; - id = "addPermissionsUsersList"; - mode = "edit"; - } + listMap = {Teams: addPermissionsTeamsList, Users: addPermissionsUsersList}; + list = listMap[scope.view]; + list_html = generateList.build({ + mode: 'edit', + list: list + }); - scope.id = id; + scope.list = listMap[scope.view]; + scope[`${list.iterator}_dataset`] = scope.dataset.data; + scope[`${list.name}`] = scope[`${list.iterator}_dataset`].results; - scope.$watch("selectedItems", function() { - scope.$emit("itemsSelected", scope.selectedItems); - }); + scope.$watch(list.name, function(){ + _.forEach(scope[`${list.name}`], isSelected); + }); - element.find(".addPermissionsList-inner") - .attr("id", id); - - generator.inject(list, { id: id, - title: false, mode: mode, scope: scope }); - - SearchInit({ scope: scope, set: set, - list: list, url: url }); - - PaginateInit({ scope: scope, - list: list, url: url, pageSize: 5 }); - - scope.search(list.iterator); - }); + function isSelected(item){ + if(_.find(scope.allSelected, {id: item.id})){ + item.isSelected = true; } - }; + return item; + } + element.append(list_html); + $compile(element.contents())(scope); } - ]; + }; +}]; diff --git a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js index 5c0513c8db..8955d30aa0 100644 --- a/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js +++ b/awx/ui/client/src/access/addPermissions/addPermissionsList/permissionsUsers.list.js @@ -7,9 +7,14 @@ export default function() { return { - searchSize: 'col-lg-12 col-md-12 col-sm-12 col-xs-12', name: 'users', iterator: 'user', + defaultSearchParams: function(term){ + return {or__username__icontains: term, + or__first_name__icontains: term, + or__last_name__icontains: term + }; + }, title: false, listTitleBadge: false, multiSelect: true, @@ -17,7 +22,6 @@ index: false, hover: true, emptyListText : 'No Users exist', - fields: { first_name: { label: 'First Name', diff --git a/awx/ui/client/src/activity-stream/activitystream.controller.js b/awx/ui/client/src/activity-stream/activitystream.controller.js index 0c16f304b7..05609d3bc5 100644 --- a/awx/ui/client/src/activity-stream/activitystream.controller.js +++ b/awx/ui/client/src/activity-stream/activitystream.controller.js @@ -8,22 +8,31 @@ * @ngdoc function * @name controllers.function:Activity Stream * @description This controller controls the activity stream. -*/ -function activityStreamController($scope, $state, subTitle, Stream, GetTargetTitle) { + */ +function activityStreamController($scope, $state, subTitle, Stream, GetTargetTitle, list, Dataset) { - // subTitle is passed in via a resolve on the route. If there is no subtitle - // generated in the resolve then we go get the targets generic title. + init(); - // Get the streams sub-title based on the target. This scope variable is leveraged - // when we define the activity stream list. Specifically it is included in the list - // title. - $scope.streamSubTitle = subTitle ? subTitle : GetTargetTitle($state.params.target); + function init() { + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - // Open the stream - Stream({ - scope: $scope - }); + // subTitle is passed in via a resolve on the route. If there is no subtitle + // generated in the resolve then we go get the targets generic title. + + // Get the streams sub-title based on the target. This scope variable is leveraged + // when we define the activity stream list. Specifically it is included in the list + // title. + $scope.streamSubTitle = subTitle ? subTitle : GetTargetTitle($state.params.target); + + // Open the stream + Stream({ + scope: $scope + }); + } } -export default ['$scope', '$state', 'subTitle', 'Stream', 'GetTargetTitle', activityStreamController]; +export default ['$scope', '$state', 'subTitle', 'Stream', 'GetTargetTitle', 'StreamList', 'Dataset', activityStreamController]; diff --git a/awx/ui/client/src/activity-stream/activitystream.partial.html b/awx/ui/client/src/activity-stream/activitystream.partial.html deleted file mode 100644 index 8c6263b11d..0000000000 --- a/awx/ui/client/src/activity-stream/activitystream.partial.html +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/awx/ui/client/src/activity-stream/activitystream.route.js b/awx/ui/client/src/activity-stream/activitystream.route.js index 4da5343651..3fa3d961c9 100644 --- a/awx/ui/client/src/activity-stream/activitystream.route.js +++ b/awx/ui/client/src/activity-stream/activitystream.route.js @@ -4,49 +4,73 @@ * All Rights Reserved *************************************************/ - import {templateUrl} from '../shared/template-url/template-url.factory'; - export default { name: 'activityStream', route: '/activity_stream?target&id', - templateUrl: templateUrl('activity-stream/activitystream'), - controller: 'activityStreamController', + searchPrefix: 'activity', data: { activityStream: true }, + params: { + activity_search: { + value: { + // default params will not generate search tags + order_by: '-timestamp', + or__object1: null, + or__object2: null + } + } + }, ncyBreadcrumb: { label: "ACTIVITY STREAM" }, - onExit: function(){ + onExit: function() { $('#stream-detail-modal').modal('hide'); $('.modal-backdrop').remove(); $('body').removeClass('modal-open'); }, - resolve: { - features: ['FeaturesService', 'ProcessErrors', '$state', '$rootScope', - function(FeaturesService, ProcessErrors, $state, $rootScope) { - var features = FeaturesService.get(); - if(features){ - if(FeaturesService.featureEnabled('activity_streams')) { - return features; - } - else { - $state.go('dashboard'); - } + views: { + 'list@': { + controller: 'activityStreamController', + templateProvider: function(StreamList, generateList) { + let html = generateList.build({ + list: StreamList, + mode: 'edit' + }); + html = generateList.wrapPanel(html); + return html; } - $rootScope.featuresConfigured.promise.then(function(features){ - if(features){ - if(FeaturesService.featureEnabled('activity_streams')) { + } + }, + resolve: { + Dataset: ['StreamList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ], + features: ['FeaturesService', 'ProcessErrors', '$state', '$rootScope', + function(FeaturesService, ProcessErrors, $state, $rootScope) { + var features = FeaturesService.get(); + if (features) { + if (FeaturesService.featureEnabled('activity_streams')) { return features; - } - else { + } else { $state.go('dashboard'); } } - }); - }], - subTitle: - [ '$stateParams', + $rootScope.featuresConfigured.promise.then(function(features) { + if (features) { + if (FeaturesService.featureEnabled('activity_streams')) { + return features; + } else { + $state.go('dashboard'); + } + } + }); + } + ], + subTitle: ['$stateParams', 'Rest', 'ModelToBasePathKey', 'GetBasePath', @@ -65,15 +89,14 @@ export default { .then(function(data) { // Return the name or the username depending on which is available. return (data.data.name || data.data.username); - }).catch(function (response) { - ProcessErrors(null, response.data, response.status, null, { - hdr: 'Error!', - msg: 'Failed to get title info. GET returned status: ' + - response.status + }).catch(function(response) { + ProcessErrors(null, response.data, response.status, null, { + hdr: 'Error!', + msg: 'Failed to get title info. GET returned status: ' + + response.status + }); }); - }); - } - else { + } else { return null; } } diff --git a/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js b/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js index 229c3d4271..8e01af25e5 100644 --- a/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js +++ b/awx/ui/client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js @@ -10,7 +10,7 @@ export default ['templateUrl', function(templateUrl) { scope: true, replace: true, templateUrl: templateUrl('activity-stream/streamDropdownNav/stream-dropdown-nav'), - controller: ['$scope', '$state', 'CreateSelect2', function($scope, $state, CreateSelect2) { + controller: ['$scope', '$state', '$stateParams','CreateSelect2', function($scope, $state, $stateParams, CreateSelect2) { $scope.streamTarget = ($state.params && $state.params.target) ? $state.params.target : 'dashboard'; @@ -35,14 +35,17 @@ export default ['templateUrl', function(templateUrl) { }); $scope.changeStreamTarget = function(){ - if($scope.streamTarget && $scope.streamTarget === 'dashboard') { // Just navigate to the base activity stream - $state.go('activityStream', {}, {inherit: false}); + $state.go('activityStream'); } else { + let search = _.merge($stateParams.activity_search, { + or__object1: $scope.streamTarget, + or__object2: $scope.streamTarget + }); // Attach the taget to the query parameters - $state.go('activityStream', {target: $scope.streamTarget}, {inherit: false}); + $state.go('activityStream', {target: $scope.streamTarget, activity_search: search}); } }; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index b7a3c25d43..7b0de854c3 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -15,6 +15,12 @@ import 'jquery.resize'; import 'codemirror'; import 'js-yaml'; import 'select2'; +import uiRouter from 'angular-ui-router'; +// backwards compatibility for $stateChange* events +import 'angular-ui-router/release/stateEvents'; +// ui-router debugging +//import { trace } from 'angular-ui-router'; +//trace.enable(); // Configuration dependencies global.$AnsibleConfig = null; @@ -27,7 +33,7 @@ if ($basePath) { // Modules import './helpers'; -import './forms'; +import * as forms from './forms'; import './lists'; import './widgets'; import './filters'; @@ -40,12 +46,10 @@ import systemTracking from './system-tracking/main'; import inventories from './inventories/main'; import inventoryScripts from './inventory-scripts/main'; import organizations from './organizations/main'; -import permissions from './permissions/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; -import access from './access/main'; import about from './about/main'; import license from './license/main'; import setupMenu from './setup-menu/main'; @@ -54,23 +58,17 @@ import breadCrumb from './bread-crumb/main'; import browserData from './browser-data/main'; import dashboard from './dashboard/main'; import moment from './shared/moment/main'; -import templateUrl from './shared/template-url/main'; import login from './login/main'; import activityStream from './activity-stream/main'; import standardOut from './standard-out/main'; import JobTemplates from './job-templates/main'; -import search from './search/main'; import credentials from './credentials/main'; import { ProjectsList, ProjectsAdd, ProjectsEdit } from './controllers/Projects'; -import OrganizationsList from './organizations/list/organizations-list.controller'; -import OrganizationsAdd from './organizations/add/organizations-add.controller'; import { UsersList, UsersAdd, UsersEdit } from './controllers/Users'; import { TeamsList, TeamsAdd, TeamsEdit } from './controllers/Teams'; import RestServices from './rest/main'; -import './lookup/main'; -import './shared/api-loader'; -import './shared/form-generator'; +import access from './access/main'; import './shared/Modal'; import './shared/prompt-dialog'; import './shared/directives'; @@ -80,7 +78,7 @@ import config from './shared/config/main'; import './login/authenticationServices/pendo/ng-pendo'; import footer from './footer/main'; import scheduler from './scheduler/main'; -import {N_} from './i18n'; +import { N_ } from './i18n'; var tower = angular.module('Tower', [ // how to add CommonJS / AMD third-party dependencies: @@ -88,17 +86,17 @@ var tower = angular.module('Tower', [ // 2. add package name to ./grunt-tasks/webpack.vendorFiles require('angular-breadcrumb'), require('angular-codemirror'), - require('angular-cookies'), require('angular-drag-and-drop-lists'), - require('angular-ui-router'), require('angular-sanitize'), require('angular-scheduler').name, require('angular-tz-extensions'), require('lr-infinite-scroll'), require('ng-toast'), - + uiRouter, + 'ui.router.state.events', about.name, + access.name, license.name, RestServices.name, browserData.name, @@ -106,14 +104,13 @@ var tower = angular.module('Tower', [ inventories.name, inventoryScripts.name, organizations.name, - permissions.name, + //permissions.name, managementJobs.name, setupMenu.name, mainMenu.name, breadCrumb.name, dashboard.name, moment.name, - templateUrl.name, login.name, activityStream.name, footer.name, @@ -121,27 +118,19 @@ var tower = angular.module('Tower', [ jobSubmission.name, notifications.name, standardOut.name, - access.name, JobTemplates.name, portalMode.name, - search.name, config.name, credentials.name, //'templates', 'Utilities', 'OrganizationFormDefinition', 'UserFormDefinition', - 'FormGenerator', 'OrganizationListDefinition', 'jobTemplates', 'UserListDefinition', 'UserHelper', 'PromptDialog', - 'ApiLoader', - 'RelatedSearchHelper', - 'SearchHelper', - 'PaginationHelpers', - 'RefreshHelper', 'AWDirectives', 'InventoriesListDefinition', 'InventoryFormDefinition', @@ -161,7 +150,6 @@ var tower = angular.module('Tower', [ 'TeamHelper', 'CredentialsListDefinition', 'CredentialFormDefinition', - 'LookUpHelper', 'JobTemplatesListDefinition', 'PortalJobTemplatesListDefinition', 'JobTemplateFormDefinition', @@ -223,10 +211,12 @@ var tower = angular.module('Tower', [ timeout: 4000 }); }]) - .config(['$stateProvider', '$urlRouterProvider', '$breadcrumbProvider', - '$urlMatcherFactoryProvider', - function($stateProvider, $urlRouterProvider, $breadcrumbProvider, - $urlMatcherFactoryProvider) { + .config(['$urlRouterProvider', '$breadcrumbProvider', 'QuerySetProvider', + '$urlMatcherFactoryProvider', 'stateDefinitionsProvider', '$stateProvider', '$stateExtenderProvider', + function($urlRouterProvider, $breadcrumbProvider, QuerySet, + $urlMatcherFactoryProvider, stateDefinitionsProvider, $stateProvider, $stateExtenderProvider) { + let $stateExtender = $stateExtenderProvider.$get(), + stateDefinitions = stateDefinitionsProvider.$get(); $urlMatcherFactoryProvider.strictMode(false); $breadcrumbProvider.setOptions({ templateUrl: urlPrefix + 'partials/breadcrumb.html' @@ -234,202 +224,133 @@ var tower = angular.module('Tower', [ // route to the details pane of /job/:id/host-event/:eventId if no other child specified $urlRouterProvider.when('/jobs/*/host-event/*', '/jobs/*/host-event/*/details'); + $urlRouterProvider.otherwise('/home'); - // $urlRouterProvider.otherwise("/home"); - $urlRouterProvider.otherwise(function($injector) { - var $state = $injector.get("$state"); - $state.go('dashboard'); + $urlMatcherFactoryProvider.type('queryset', { + // encoding + // from {operator__key1__comparator=value, ... } + // to "_search=operator:key:compator=value& ... " + encode: function(item) { + return QuerySet.$get().encodeArr(item); + }, + // decoding + // from "_search=operator:key:compator=value& ... " + // to "_search=operator:key:compator=value& ... " + decode: function(item) { + return QuerySet.$get().decodeArr(item); + }, + // directionality - are we encoding or decoding? + is: function(item) { + // true: encode to uri + // false: decode to $stateParam + return angular.isObject(item); + } }); - /* Mark translatable strings with N_() and - * extract them by 'grunt nggettext_extract' - * but angular.config() cannot get gettextCatalog. - */ - $stateProvider. - state('teams', { - url: '/teams', - templateUrl: urlPrefix + 'partials/teams.html', - controller: TeamsList, - data: { - activityStream: true, - activityStreamTarget: 'team' - }, - ncyBreadcrumb: { - parent: 'setup', - label: N_("TEAMS") - } - }). - state('teams.add', { - url: '/add', - templateUrl: urlPrefix + 'partials/teams.html', - controller: TeamsAdd, - ncyBreadcrumb: { - parent: "teams", - label: N_("CREATE TEAM") - } - }). + // Handy hook for debugging register/deregister of lazyLoad'd states + // $stateProvider.stateRegistry.onStatesChanged((event, states) =>{ + // console.log(event, states) + // }) - state('teams.edit', { - url: '/:team_id', - templateUrl: urlPrefix + 'partials/teams.html', - controller: TeamsEdit, - data: { - activityStreamId: 'team_id' - }, - ncyBreadcrumb: { - parent: "teams", - label: "{{team_obj.name}}" - } - }). - state('teamUsers', { - url: '/teams/:team_id/users', - templateUrl: urlPrefix + 'partials/teams.html', - controller: UsersList - }). + // lazily generate a tree of substates which will replace this node in ui-router's stateRegistry + // see: stateDefinition.factory for usage documentation + $stateProvider.state({ + name: 'projects', + url: '/projects', + lazyLoad: () => stateDefinitions.generateTree({ + parent: 'projects', // top-most node in the generated tree (will replace this state definition) + modes: ['add', 'edit'], + list: 'ProjectList', + form: 'ProjectsForm', + controllers: { + list: ProjectsList, // DI strings or objects + add: ProjectsAdd, + edit: ProjectsEdit + }, + data: { + activityStream: true, + activityStreamTarget: 'project', + socket: { + "groups": { + "jobs": ["status_changed"] + } + } + } + }) + }); - state('teamUserEdit', { - url: '/teams/:team_id/users/:user_id', - templateUrl: urlPrefix + 'partials/teams.html', - controller: UsersEdit - }). - - state('teamProjects', { - url: '/teams/:team_id/projects', - templateUrl: urlPrefix + 'partials/teams.html', - controller: ProjectsList - }). - - state('teamProjectAdd', { - url: '/teams/:team_id/projects/add', - templateUrl: urlPrefix + 'partials/teams.html', - controller: ProjectsAdd - }). - - state('teamProjectEdit', { - url: '/teams/:team_id/projects/:project_id', - templateUrl: urlPrefix + 'partials/teams.html', - controller: ProjectsEdit - }). - - state('teamCredentials', { - url: '/teams/:team_id/credentials', - templateUrl: urlPrefix + 'partials/teams.html', - controller: CredentialsList - }). - - state('teamCredentialAdd', { - url: '/teams/:team_id/credentials/add', - templateUrl: urlPrefix + 'partials/teams.html', - controller: CredentialsAdd - }). - - state('teamCredentialEdit', { - url: '/teams/:team_id/credentials/:credential_id', - templateUrl: urlPrefix + 'partials/teams.html', - controller: CredentialsEdit - }). - - state('credentials', { + $stateProvider.state({ + name: 'credentials', url: '/credentials', - templateUrl: urlPrefix + 'partials/credentials.html', - controller: CredentialsList, - data: { - activityStream: true, - activityStreamTarget: 'credential' - }, - ncyBreadcrumb: { - parent: 'setup', - label: N_("CREDENTIALS") - } - }). + lazyLoad: () => stateDefinitions.generateTree({ + parent: 'credentials', + modes: ['add', 'edit'], + list: 'CredentialList', + form: 'CredentialForm', + controllers: { + list: CredentialsList, + add: CredentialsAdd, + edit: CredentialsEdit + }, + data: { + activityStream: true, + activityStreamTarget: 'credential' + }, + ncyBreadcrumb: { + parent: 'setup', + label: 'CREDENTIALS' + } + }) + }); - state('credentials.add', { - url: '/add', - templateUrl: urlPrefix + 'partials/credentials.html', - controller: CredentialsAdd, - ncyBreadcrumb: { - parent: "credentials", - label: N_("CREATE CREDENTIAL") - } - }). + $stateProvider.state({ + name: 'teams', + url: '/teams', + lazyLoad: () => stateDefinitions.generateTree({ + parent: 'teams', + modes: ['add', 'edit'], + list: 'TeamList', + form: 'TeamForm', + controllers: { + list: TeamsList, + add: TeamsAdd, + edit: TeamsEdit + }, + data: { + activityStream: true, + activityStreamTarget: 'team' + }, + ncyBreadcrumb: { + parent: 'setup', + label: 'TEAMS' + } + }) + }); - state('credentials.edit', { - url: '/:credential_id', - templateUrl: urlPrefix + 'partials/credentials.html', - controller: CredentialsEdit, - data: { - activityStreamId: 'credential_id' - }, - ncyBreadcrumb: { - parent: "credentials", - label: "{{credential_obj.name}}" - } - }). - - state('users', { + $stateProvider.state({ + name: 'users', url: '/users', - templateUrl: urlPrefix + 'partials/users.html', - controller: UsersList, - data: { - activityStream: true, - activityStreamTarget: 'user' - }, - ncyBreadcrumb: { - parent: 'setup', - label: N_("USERS") - } - }). - - state('users.add', { - url: '/add', - templateUrl: urlPrefix + 'partials/users.html', - controller: UsersAdd, - ncyBreadcrumb: { - parent: "users", - label: N_("CREATE USER") - } - }). - - state('users.edit', { - url: '/:user_id', - templateUrl: urlPrefix + 'partials/users.html', - controller: UsersEdit, - data: { - activityStreamId: 'user_id' - }, - ncyBreadcrumb: { - parent: "users", - label: "{{user_obj.username}}" - } - }). - - state('userCredentials', { - url: '/users/:user_id/credentials', - templateUrl: urlPrefix + 'partials/users.html', - controller: CredentialsList - }). - - state('userCredentialAdd', { - url: '/users/:user_id/credentials/add', - templateUrl: urlPrefix + 'partials/teams.html', - controller: CredentialsAdd - }). - - state('teamUserCredentialEdit', { - url: '/teams/:user_id/credentials/:credential_id', - templateUrl: urlPrefix + 'partials/teams.html', - controller: CredentialsEdit - }). - - state('sockets', { - url: '/sockets', - templateUrl: urlPrefix + 'partials/sockets.html', - controller: SocketsController, - ncyBreadcrumb: { - label: N_("SOCKETS") - } + lazyLoad: () => stateDefinitions.generateTree({ + parent: 'users', + modes: ['add', 'edit'], + list: 'UserList', + form: 'UserForm', + controllers: { + list: UsersList, + add: UsersAdd, + edit: UsersEdit + }, + data: { + activityStream: true, + activityStreamTarget: 'user' + }, + ncyBreadcrumb: { + parent: 'setup', + label: 'USERS' + } + }) }); } ]) @@ -447,17 +368,23 @@ var tower = angular.module('Tower', [ }]); }]) -.run(['$stateExtender', '$q', '$compile', '$cookieStore', '$rootScope', '$log', +.run(['$stateExtender', '$q', '$compile', '$cookieStore', '$rootScope', '$log', '$stateParams', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'LoadConfig', 'Store', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', 'GetBasePath', 'ConfigService', 'FeaturesService', '$filter', 'SocketService', 'I18NInit', - function($stateExtender, $q, $compile, $cookieStore, $rootScope, $log, + function($stateExtender, $q, $compile, $cookieStore, $rootScope, $log, $stateParams, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, LoadConfig, Store, pendoService, Prompt, Rest, Wait, ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService, $filter, SocketService, I18NInit) { + $rootScope.$state = $state; + $rootScope.$state.matches = function(stateName) { + return $state.current.name.search(stateName) > 0; + }; + $rootScope.$stateParams = $stateParams; + I18NInit(); $stateExtender.addState({ name: 'dashboard', @@ -465,14 +392,14 @@ var tower = angular.module('Tower', [ templateUrl: urlPrefix + 'partials/home.html', controller: Home, params: { licenseMissing: null }, - socket: { - "groups":{ - "jobs": ["status_changed"] - } - }, data: { activityStream: true, - refreshButton: true + refreshButton: true, + socket: { + "groups": { + "jobs": ["status_changed"] + } + }, }, ncyBreadcrumb: { label: N_("DASHBOARD") @@ -491,94 +418,94 @@ var tower = angular.module('Tower', [ }); $stateExtender.addState({ + searchPrefix: 'job', name: 'jobs', url: '/jobs', - templateUrl: urlPrefix + 'partials/jobs.html', - controller: JobsListController, ncyBreadcrumb: { label: N_("JOBS") }, params: { - search: { - value: {order_by:'-finished'} + job_search: { + value: { order_by: '-finished' } } }, - socket: { - "groups":{ - "jobs": ["status_changed"], - "schedules": ["changed"] - } - } - }); - - $stateExtender.addState({ - name: 'projects', - url: '/projects?{status}', - templateUrl: urlPrefix + 'partials/projects.html', - controller: ProjectsList, data: { - activityStream: true, - activityStreamTarget: 'project' + socket: { + "groups": { + "jobs": ["status_changed"], + "schedules": ["changed"] + } + } }, - ncyBreadcrumb: { - label: N_("PROJECTS") + resolve: { + Dataset: ['AllJobsList', 'QuerySet', '$stateParams', 'GetBasePath', (list, qs, $stateParams, GetBasePath) => { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + }] }, - socket: { - "groups":{ - "jobs": ["status_changed"] + views: { + 'list@': { + templateUrl: urlPrefix + 'partials/jobs.html', + }, + 'list@jobs': { + templateProvider: function(AllJobsList, generateList) { + let html = generateList.build({ + list: AllJobsList, + mode: 'edit' + }); + return html; + }, + controller: JobsListController } } }); + $stateExtender.addState({ - name: 'projects.add', - url: '/add', - templateUrl: urlPrefix + 'partials/projects.html', - controller: ProjectsAdd, - ncyBreadcrumb: { - parent: "projects", - label: N_("CREATE PROJECT") - }, - socket: { - "groups":{ - "jobs": ["status_changed"] - } + name: 'teamUsers', + url: '/teams/:team_id/users', + templateUrl: urlPrefix + 'partials/teams.html', + controller: UsersList, + resolve: { + Users: ['UsersList', 'QuerySet', '$stateParams', 'GetBasePath', (list, qs, $stateParams, GetBasePath) => { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + }] } }); + $stateExtender.addState({ - name: 'projects.edit', - url: '/:id', - templateUrl: urlPrefix + 'partials/projects.html', - controller: ProjectsEdit, - data: { - activityStreamId: 'id' - }, + name: 'userCredentials', + url: '/users/:user_id/credentials', + templateUrl: urlPrefix + 'partials/users.html', + controller: CredentialsList + }); + + $stateExtender.addState({ + name: 'userCredentialAdd', + url: '/users/:user_id/credentials/add', + templateUrl: urlPrefix + 'partials/teams.html', + controller: CredentialsAdd + }); + + $stateExtender.addState({ + name: 'teamUserCredentialEdit', + url: '/teams/:user_id/credentials/:credential_id', + templateUrl: urlPrefix + 'partials/teams.html', + controller: CredentialsEdit + }); + + $stateExtender.addState({ + name: 'sockets', + url: '/sockets', + templateUrl: urlPrefix + 'partials/sockets.html', + controller: SocketsController, ncyBreadcrumb: { - parent: 'projects', - label: '{{name}}' - }, - socket: { - "groups":{ - "jobs": ["status_changed"] - } + label: 'SOCKETS' } }); - $stateExtender.addState({ - name: 'projectOrganizations', - url: '/projects/:project_id/organizations', - templateUrl: urlPrefix + 'partials/projects.html', - controller: OrganizationsList - }); - - $stateExtender.addState({ - name: 'projectOrganizationAdd', - url: '/projects/:project_id/organizations/add', - templateUrl: urlPrefix + 'partials/projects.html', - controller: OrganizationsAdd - }); - $rootScope.addPermission = function(scope) { $compile("")(scope); }; @@ -604,7 +531,7 @@ var tower = angular.module('Tower', [ Rest.post({ "disassociate": true, "id": entry.id }) .success(function() { Wait('stop'); - $rootScope.$broadcast("refreshList", "permission"); + $state.go('.', null, { reload: true }); }) .error(function(data, status) { ProcessErrors($rootScope, data, status, null, { diff --git a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js index cfc6630412..36fe3901ee 100644 --- a/awx/ui/client/src/bread-crumb/bread-crumb.directive.js +++ b/awx/ui/client/src/bread-crumb/bread-crumb.directive.js @@ -25,18 +25,29 @@ export default if(streamConfig && streamConfig.activityStream) { if(streamConfig.activityStreamTarget) { stateGoParams.target = streamConfig.activityStreamTarget; + stateGoParams.activity_search = { + or__object1: streamConfig.activityStreamTarget, + or__object2: streamConfig.activityStreamTarget, + order_by: '-timestamp', + page_size: '20', + }; + } + else { + stateGoParams.activity_search = { + order_by: '-timestamp', + page_size: '20', + }; } if(streamConfig.activityStreamId) { stateGoParams.id = $state.params[streamConfig.activityStreamId]; } + } originalRoute = $state.current; $state.go('activityStream', stateGoParams); } // The user is navigating away from the activity stream - take them back from whence they came else { - // Pull the previous state out of local storage - if(originalRoute) { $state.go(originalRoute.name, originalRoute.fromParams); } @@ -51,14 +62,13 @@ export default }; scope.$on("$stateChangeStart", function updateActivityStreamButton(event, toState, toParams, fromState, fromParams) { - if(fromState && !Empty(fromState.name)) { // Go ahead and attach the from params to the state object so that it can all be stored together fromState.fromParams = fromParams ? fromParams : {}; // Store the state that we're coming from in local storage to be accessed when navigating away from the // activity stream - Store('previous_state', fromState); + //Store('previous_state', fromState); } streamConfig = (toState && toState.data) ? toState.data : {}; diff --git a/awx/ui/client/src/controllers/Credentials.js b/awx/ui/client/src/controllers/Credentials.js index 11e2b4e435..f8ebbf2197 100644 --- a/awx/ui/client/src/controllers/Credentials.js +++ b/awx/ui/client/src/controllers/Credentials.js @@ -8,114 +8,63 @@ * @ngdoc function * @name controllers.function:Credentials * @description This controller's for the credentials page -*/ + */ export function CredentialsList($scope, $rootScope, $location, $log, - $stateParams, Rest, Alert, CredentialList, GenerateList, Prompt, SearchInit, - PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, - SelectionInit, GetChoices, Wait, $state, $filter, rbacUiControlService) { + $stateParams, Rest, Alert, CredentialList, Prompt, ClearScope, + ProcessErrors, GetBasePath, Wait, $state, $filter, rbacUiControlService, Dataset) { + ClearScope(); - rbacUiControlService.canAdd('credentials') - .then(function(canAdd) { - $scope.canAdd = canAdd; - }); - - Wait('start'); - var list = CredentialList, - defaultUrl = GetBasePath('credentials'), - view = GenerateList, - base = $location.path().replace(/^\//, '').split('/')[0], - mode = (base === 'credentials') ? 'edit' : 'select', - url; + defaultUrl = GetBasePath('credentials'); - view.inject(list, { mode: mode, scope: $scope }); + init(); - $scope.selected = []; - $scope.credentialLoading = true; + function init() { + rbacUiControlService.canAdd('credentials') + .then(function(canAdd) { + $scope.canAdd = canAdd; + }); - url = GetBasePath(base) + ( (base === 'users') ? $stateParams.user_id + '/credentials/' : $stateParams.team_id + '/credentials/' ); - if (mode === 'select') { - SelectionInit({ scope: $scope, list: list, url: url, returnToCaller: 1 }); + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + $scope.selected = []; } - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - var i, j; - - // Cleanup after a delete - Wait('stop'); - $('#prompt-modal').modal('hide'); - - list.fields.kind.searchOptions = $scope.credential_kind_options_list; - - // Translate the kind value - for (i = 0; i < $scope.credentials.length; i++) { - for (j = 0; j < $scope.credential_kind_options_list.length; j++) { - if ($scope.credential_kind_options_list[j].value === $scope.credentials[i].kind) { - $scope.credentials[i].kind = $scope.credential_kind_options_list[j].label; - break; - } - } - } - }); - - if ($scope.removeChoicesReady) { - $scope.removeChoicesReady(); - } - $scope.removeChoicesReady = $scope.$on('choicesReadyCredential', function () { - SearchInit({ - scope: $scope, - set: 'credentials', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - $scope.search(list.iterator); - }); - - // Load the list of options for Kind - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'kind', - variable: 'credential_kind_options_list', - callback: 'choicesReadyCredential' - }); - - $scope.addCredential = function () { - $state.transitionTo('credentials.add'); + $scope.addCredential = function() { + $state.go('credentials.add'); }; - $scope.editCredential = function (id) { - $state.transitionTo('credentials.edit', {credential_id: id}); + $scope.editCredential = function(id) { + $state.go('credentials.edit', { credential_id: id }); }; - $scope.deleteCredential = function (id, name) { - var action = function () { + $scope.deleteCredential = function(id, name) { + var action = function() { $('#prompt-modal').modal('hide'); Wait('start'); var url = defaultUrl + id + '/'; Rest.setUrl(url); Rest.destroy() - .success(function () { + .success(function() { + if (parseInt($state.params.credential_id) === id) { - $state.go("^", null, {reload: true}); + $state.go("^", null, { reload: true }); } else { - $scope.search(list.iterator); + // @issue: OLD SEARCH + // $scope.search(list.iterator); } }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status + }); }); }; @@ -126,97 +75,74 @@ export function CredentialsList($scope, $rootScope, $location, $log, actionText: 'DELETE' }); }; - - $scope.$emit('choicesReadyCredential'); } CredentialsList.$inject = ['$scope', '$rootScope', '$location', '$log', - '$stateParams', 'Rest', 'Alert', 'CredentialList', 'generateList', 'Prompt', - 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', - 'ProcessErrors', 'GetBasePath', 'SelectionInit', 'GetChoices', 'Wait', - '$state', '$filter', 'rbacUiControlService' + '$stateParams', 'Rest', 'Alert', 'CredentialList', 'Prompt', 'ClearScope', + 'ProcessErrors', 'GetBasePath', 'Wait', '$state', '$filter', 'rbacUiControlService', 'Dataset' ]; export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, $stateParams, CredentialForm, GenerateForm, Rest, Alert, ProcessErrors, - ReturnToCaller, ClearScope, GenerateList, SearchInit, PaginateInit, - LookUpInit, OrganizationList, GetBasePath, GetChoices, Empty, KindChange, + ClearScope, GetBasePath, GetChoices, Empty, KindChange, OwnerChange, FormSave, $state, CreateSelect2) { ClearScope(); // Inject dynamic view var form = CredentialForm, - generator = GenerateForm, defaultUrl = GetBasePath('credentials'), url; - $scope.keyEntered = false; - $scope.permissionsTooltip = 'Please save before assigning permissions'; - generator.inject(form, { mode: 'add', related: false, scope: $scope }); - generator.reset(); + init(); - // Load the list of options for Kind - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'kind', - variable: 'credential_kind_options' - }); + function init() { + // Load the list of options for Kind + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'kind', + variable: 'credential_kind_options' + }); - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'become_method', - variable: 'become_options' - }); + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'become_method', + variable: 'become_options' + }); - CreateSelect2({ - element: '#credential_become_method', - multiple: false - }); + CreateSelect2({ + element: '#credential_become_method', + multiple: false + }); - CreateSelect2({ - element: '#credential_kind', - multiple: false - }); + CreateSelect2({ + element: '#credential_kind', + multiple: false + }); - $scope.canShareCredential = false; + // apply form definition's default field values + GenerateForm.applyDefaults(form, $scope); - $rootScope.$watch('current_user', function(){ - try { - if ($rootScope.current_user.is_superuser) { - $scope.canShareCredential = true; - } else { - Rest.setUrl(`/api/v1/users/${$rootScope.current_user.id}/admin_of_organizations`); - Rest.get() - .success(function(data) { - $scope.canShareCredential = (data.count) ? true : false; - }).error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status }); - }); - } + $scope.keyEntered = false; + $scope.permissionsTooltip = 'Please save before assigning permissions'; - - var orgUrl = ($rootScope.current_user.is_superuser) ? - GetBasePath("organizations") : - $rootScope.current_user.url + "admin_of_organizations?"; - - // Create LookUpInit for organizations - LookUpInit({ - scope: $scope, - url: orgUrl, - form: form, - list: OrganizationList, - field: 'organization', - input_type: 'radio', - autopopulateLookup: false - }); + // determine if the currently logged-in user may share this credential + // previous commentary said: "$rootScope.current_user isn't available because a call to the config endpoint hasn't finished resolving yet" + // I'm 99% sure this state's will never resolve block will be rejected if setup surrounding config endpoint hasn't completed + if ($rootScope.current_user && $rootScope.current_user.is_superuser) { + $scope.canShareCredential = true; + } else { + Rest.setUrl(`/api/v1/users/${$rootScope.current_user.id}/admin_of_organizations`); + Rest.get() + .success(function(data) { + $scope.canShareCredential = (data.count) ? true : false; + }).error(function(data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status }); + }); } - catch(err){ - // $rootScope.current_user isn't available because a call to the config endpoint hasn't finished resolving yet - } - }); + } if (!Empty($stateParams.user_id)) { // Get the username based on incoming route @@ -226,10 +152,10 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, url = GetBasePath('users') + $stateParams.user_id + '/'; Rest.setUrl(url); Rest.get() - .success(function (data) { + .success(function(data) { $scope.user_username = data.username; }) - .error(function (data, status) { + .error(function(data, status) { ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve user. GET status: ' + status }); }); } else if (!Empty($stateParams.team_id)) { @@ -240,10 +166,10 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, url = GetBasePath('teams') + $stateParams.team_id + '/'; Rest.setUrl(url); Rest.get() - .success(function (data) { + .success(function(data) { $scope.team_name = data.name; }) - .error(function (data, status) { + .error(function(data, status) { ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve team. GET status: ' + status }); }); } else { @@ -263,32 +189,30 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, }); // Handle Kind change - $scope.kindChange = function () { + $scope.kindChange = function() { KindChange({ scope: $scope, form: form, reset: true }); }; // Save - $scope.formSave = function () { - generator.clearApiErrors(); - generator.checkAutoFill(); + $scope.formSave = function() { if ($scope[form.name + '_form'].$valid) { FormSave({ scope: $scope, mode: 'add' }); } }; - $scope.formCancel = function () { - $state.transitionTo('credentials'); + $scope.formCancel = function() { + $state.go('credentials'); }; // Password change - $scope.clearPWConfirm = function (fld) { + $scope.clearPWConfirm = function(fld) { // If password value changes, make sure password_confirm must be re-entered $scope[fld] = ''; $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false); }; // Respond to 'Ask at runtime?' checkbox - $scope.ask = function (fld, associated) { + $scope.ask = function(fld, associated) { if ($scope[fld + '_ask']) { $scope[fld] = 'ASK'; $("#" + form.name + "_" + fld + "_input").attr("type", "text"); @@ -313,7 +237,7 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, }; // Click clear button - $scope.clear = function (fld, associated) { + $scope.clear = function(fld, associated) { $scope[fld] = ''; $scope[associated] = ''; $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true); @@ -324,54 +248,88 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log, CredentialsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', '$stateParams', 'CredentialForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'generateList', - 'SearchInit', 'PaginateInit', 'LookUpInit', 'OrganizationList', - 'GetBasePath', 'GetChoices', 'Empty', 'KindChange', 'OwnerChange', - 'FormSave', '$state', 'CreateSelect2' + 'ProcessErrors', 'ClearScope', 'GetBasePath', 'GetChoices', 'Empty', 'KindChange', + 'OwnerChange', 'FormSave', '$state', 'CreateSelect2' ]; - export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, - $stateParams, CredentialForm, GenerateForm, Rest, Alert, ProcessErrors, - RelatedSearchInit, RelatedPaginateInit, ReturnToCaller, ClearScope, Prompt, - GetBasePath, GetChoices, KindChange, OrganizationList, LookUpInit, Empty, - OwnerChange, FormSave, Wait, $state, CreateSelect2, Authorization) { - if (!$rootScope.current_user) { - Authorization.restoreUserInfo(); - } + $stateParams, CredentialForm, Rest, Alert, ProcessErrors, ClearScope, Prompt, + GetBasePath, GetChoices, KindChange, Empty, OwnerChange, FormSave, Wait, + $state, CreateSelect2, Authorization) { ClearScope(); var defaultUrl = GetBasePath('credentials'), - generator = GenerateForm, form = CredentialForm, base = $location.path().replace(/^\//, '').split('/')[0], master = {}, - id = $stateParams.credential_id, - relatedSets = {}; + id = $stateParams.credential_id; - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - generator.reset(); - $scope.id = id; + init(); - $scope.$watch('credential_obj.summary_fields.user_capabilities.edit', function(val) { - if (val === false) { - $scope.canAdd = false; + function init() { + $scope.id = id; + $scope.$watch('credential_obj.summary_fields.user_capabilities.edit', function(val) { + if (val === false) { + $scope.canAdd = false; + } + }); + + $scope.canShareCredential = false; + Wait('start'); + if (!$rootScope.current_user) { + Authorization.restoreUserInfo(); } - }); + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'kind', + variable: 'credential_kind_options', + callback: 'choicesReadyCredential' + }); - $scope.canShareCredential = false; + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'become_method', + variable: 'become_options' + }); - if ($rootScope.current_user.is_superuser) { - $scope.canShareCredential = true; - } else { - Rest.setUrl(`/api/v1/users/${$rootScope.current_user.id}/admin_of_organizations`); - Rest.get() - .success(function(data) { - $scope.canShareCredential = (data.count) ? true : false; - }).error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status }); - }); + if ($rootScope.current_user && $rootScope.current_user.is_superuser) { + $scope.canShareCredential = true; + } else { + Rest.setUrl(`/api/v1/users/${$rootScope.current_user.id}/admin_of_organizations`); + Rest.get() + .success(function(data) { + $scope.canShareCredential = (data.count) ? true : false; + }).error(function(data, status) { + ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to find if users is admin of org' + status }); + }); + } + + // if the credential is assigned to an organization, allow permission delegation + // do NOT use $scope.organization in a view directive to determine if a credential is associated with an org + // @todo why not? ^ and what is this type check for a number doing - should this be a type check for undefined? + $scope.disablePermissionAssignment = typeof($scope.organization) === 'number' ? false : true; + if ($scope.disablePermissionAssignment) { + $scope.permissionsTooltip = 'Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned.'; + } + setAskCheckboxes(); + KindChange({ + scope: $scope, + form: form, + reset: false + }); + OwnerChange({ scope: $scope }); + $scope.$watch("ssh_key_data", function(val) { + if (val === "" || val === null || val === undefined) { + $scope.keyEntered = false; + $scope.ssh_key_unlock_ask = false; + $scope.ssh_key_unlock = ""; + } else { + $scope.keyEntered = true; + } + }); } function setAskCheckboxes() { @@ -398,64 +356,14 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, } } } - - if ($scope.removeCredentialLoaded) { - $scope.removeCredentialLoaded(); - } - $scope.removeCredentialLoaded = $scope.$on('credentialLoaded', function () { - // if the credential is assigned to an organization, allow permission delegation - // do NOT use $scope.organization in a view directive to determine if a credential is associated with an org - $scope.disablePermissionAssignment = typeof($scope.organization) === 'number' ? false : true; - if ($scope.disablePermissionAssignment){ - $scope.permissionsTooltip = 'Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned.'; - } - var set; - for (set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - var orgUrl = ($rootScope.current_user.is_superuser) ? - GetBasePath("organizations") : - $rootScope.current_user.url + "admin_of_organizations?"; - - // create LookUpInit for organizations - LookUpInit({ - scope: $scope, - url: orgUrl, - form: form, - current_item: $scope.organization, - list: OrganizationList, - field: 'organization', - input_type: 'radio', - autopopulateLookup: false - }); - - setAskCheckboxes(); - KindChange({ - scope: $scope, - form: form, - reset: false - }); - OwnerChange({ scope: $scope }); - $scope.$watch("ssh_key_data", function(val) { - if (val === "" || val === null || val === undefined) { - $scope.keyEntered = false; - $scope.ssh_key_unlock_ask = false; - $scope.ssh_key_unlock = ""; - } else { - $scope.keyEntered = true; - } - }); - Wait('stop'); - }); - if ($scope.removeChoicesReady) { $scope.removeChoicesReady(); } - $scope.removeChoicesReady = $scope.$on('choicesReadyCredential', function () { + $scope.removeChoicesReady = $scope.$on('choicesReadyCredential', function() { // Retrieve detail record and prepopulate the form Rest.setUrl(defaultUrl + ':id/'); Rest.get({ params: { id: id } }) - .success(function (data) { + .success(function(data) { if (data && data.summary_fields && data.summary_fields.organization && data.summary_fields.organization.id) { @@ -481,7 +389,6 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField]; } } - relatedSets = form.relatedSets(data.related); if (!Empty($scope.user)) { $scope.owner = 'user'; @@ -530,118 +437,93 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, }); switch (data.kind) { - case 'aws': - $scope.access_key = data.username; - $scope.secret_key = data.password; - master.access_key = $scope.access_key; - master.secret_key = $scope.secret_key; - break; - case 'ssh': - $scope.ssh_password = data.password; - master.ssh_password = $scope.ssh_password; - break; - case 'rax': - $scope.api_key = data.password; - master.api_key = $scope.api_key; - break; - case 'gce': - $scope.email_address = data.username; - $scope.project = data.project; - break; - case 'azure': - $scope.subscription = data.username; - break; + case 'aws': + $scope.access_key = data.username; + $scope.secret_key = data.password; + master.access_key = $scope.access_key; + master.secret_key = $scope.secret_key; + break; + case 'ssh': + $scope.ssh_password = data.password; + master.ssh_password = $scope.ssh_password; + break; + case 'rax': + $scope.api_key = data.password; + master.api_key = $scope.api_key; + break; + case 'gce': + $scope.email_address = data.username; + $scope.project = data.project; + break; + case 'azure': + $scope.subscription = data.username; + break; } $scope.credential_obj = data; - RelatedSearchInit({ - scope: $scope, - form: form, - relatedSets: relatedSets - }); - RelatedPaginateInit({ - scope: $scope, - relatedSets: relatedSets - }); - $scope.$emit('credentialLoaded'); }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to retrieve Credential: ' + $stateParams.id + '. GET status: ' + status }); + .error(function(data, status) { + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to retrieve Credential: ' + $stateParams.id + '. GET status: ' + status + }); }); }); - Wait('start'); - - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'kind', - variable: 'credential_kind_options', - callback: 'choicesReadyCredential' - }); - - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'become_method', - variable: 'become_options' - }); - // Save changes to the parent - $scope.formSave = function () { - generator.clearApiErrors(); - generator.checkAutoFill({ scope: $scope }); + $scope.formSave = function() { if ($scope[form.name + '_form'].$valid) { FormSave({ scope: $scope, mode: 'edit' }); } }; // Handle Owner change - $scope.ownerChange = function () { + $scope.ownerChange = function() { OwnerChange({ scope: $scope }); }; // Handle Kind change - $scope.kindChange = function () { + $scope.kindChange = function() { KindChange({ scope: $scope, form: form, reset: true }); }; - $scope.formCancel = function () { + $scope.formCancel = function() { $state.transitionTo('credentials'); }; // Related set: Add button - $scope.add = function (set) { + $scope.add = function(set) { $rootScope.flashMessage = null; $location.path('/' + base + '/' + $stateParams.id + '/' + set + '/add'); }; // Related set: Edit button - $scope.edit = function (set, id) { + $scope.edit = function(set, id) { $rootScope.flashMessage = null; $location.path('/' + base + '/' + $stateParams.id + '/' + set + '/' + id); }; // Related set: Delete button - $scope['delete'] = function (set, itm_id, name, title) { + $scope['delete'] = function(set, itm_id, name, title) { $rootScope.flashMessage = null; - var action = function () { + var action = function() { var url = defaultUrl + id + '/' + set + '/'; Rest.setUrl(url); Rest.post({ - id: itm_id, - disassociate: 1 - }) - .success(function () { - $('#prompt-modal').modal('hide'); - $scope.search(form.related[set].iterator); + id: itm_id, + disassociate: 1 }) - .error(function (data, status) { + .success(function() { $('#prompt-modal').modal('hide'); - ProcessErrors($scope, data, status, null, { hdr: 'Error!', + // @issue: OLD SEARCH + // $scope.search(form.related[set].iterator); + }) + .error(function(data, status) { + $('#prompt-modal').modal('hide'); + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', msg: 'Call to ' + url + ' failed. POST returned status: ' + status }); }); @@ -657,14 +539,14 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, }; // Password change - $scope.clearPWConfirm = function (fld) { + $scope.clearPWConfirm = function(fld) { // If password value changes, make sure password_confirm must be re-entered $scope[fld] = ''; $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false); }; // Respond to 'Ask at runtime?' checkbox - $scope.ask = function (fld, associated) { + $scope.ask = function(fld, associated) { if ($scope[fld + '_ask']) { $scope[fld] = 'ASK'; $("#" + form.name + "_" + fld + "_input").attr("type", "text"); @@ -688,7 +570,7 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, } }; - $scope.clear = function (fld, associated) { + $scope.clear = function(fld, associated) { $scope[fld] = ''; $scope[associated] = ''; $scope[form.name + '_form'][associated].$setValidity('awpassmatch', true); @@ -698,9 +580,8 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log, } CredentialsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', - '$log', '$stateParams', 'CredentialForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', - 'ReturnToCaller', 'ClearScope', 'Prompt', 'GetBasePath', 'GetChoices', - 'KindChange', 'OrganizationList', 'LookUpInit', 'Empty', 'OwnerChange', + '$log', '$stateParams', 'CredentialForm', 'Rest', 'Alert', + 'ProcessErrors', 'ClearScope', 'Prompt', 'GetBasePath', 'GetChoices', + 'KindChange', 'Empty', 'OwnerChange', 'FormSave', 'Wait', '$state', 'CreateSelect2', 'Authorization' ]; diff --git a/awx/ui/client/src/controllers/JobEvents.js b/awx/ui/client/src/controllers/JobEvents.js index bf547dd859..0ea23a76ce 100644 --- a/awx/ui/client/src/controllers/JobEvents.js +++ b/awx/ui/client/src/controllers/JobEvents.js @@ -12,15 +12,17 @@ export function JobEventsList($sce, $filter, $scope, $rootScope, $location, $log, $stateParams, Rest, Alert, JobEventList, GenerateList, - Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, LookUpInit, ToggleChildren, - FormatDate, EventView, Refresh, Wait) { + Prompt, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, LookUpInit, ToggleChildren, + FormatDate, EventView, Wait) { ClearScope(); var list = JobEventList, - defaultUrl = GetBasePath('jobs') + $stateParams.id + '/job_events/', //?parent__isnull=1'; - generator = GenerateList, - page; + generator = GenerateList; + + // @issue: OLD SEARCH + // var defaultUrl = GetBasePath('jobs') + $stateParams.id + '/job_events/', //?parent__isnull=1'; + // page; list.base = $location.path(); $scope.job_id = $stateParams.id; @@ -191,30 +193,31 @@ export function JobEventsList($sce, $filter, $scope, $rootScope, $location, $log }); }); - SearchInit({ - scope: $scope, - set: 'jobevents', - list: list, - url: defaultUrl - }); - - page = ($stateParams.page) ? parseInt($stateParams.page,10) - 1 : null; - - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl, - page: page - }); - - // Called from Inventories tab, host failed events link: - if ($stateParams.host) { - $scope[list.iterator + 'SearchField'] = 'host'; - $scope[list.iterator + 'SearchValue'] = $stateParams.host; - $scope[list.iterator + 'SearchFieldLabel'] = list.fields.host.label; - } - - $scope.search(list.iterator, $stateParams.page); + // @issue: OLD SEARCH + // SearchInit({ + // scope: $scope, + // set: 'jobevents', + // list: list, + // url: defaultUrl + // }); + // + // page = ($stateParams.page) ? parseInt($stateParams.page,10) - 1 : null; + // + // PaginateInit({ + // scope: $scope, + // list: list, + // url: defaultUrl, + // page: page + // }); + // + // // Called from Inventories tab, host failed events link: + // if ($stateParams.host) { + // $scope[list.iterator + 'SearchField'] = 'host'; + // $scope[list.iterator + 'SearchValue'] = $stateParams.host; + // $scope[list.iterator + 'SearchFieldLabel'] = list.fields.host.label; + // } + // + // $scope.search(list.iterator, $stateParams.page); $scope.toggle = function (id) { ToggleChildren({ @@ -231,21 +234,24 @@ export function JobEventsList($sce, $filter, $scope, $rootScope, $location, $log }; $scope.refresh = function () { - $scope.jobSearchSpin = true; + // @issue: OLD SEARCH + // $scope.jobSearchSpin = true; $scope.jobLoading = true; Wait('start'); - Refresh({ - scope: $scope, - set: 'jobevents', - iterator: 'jobevent', - url: $scope.current_url - }); + + // @issue: OLD SEARCH + // Refresh({ + // scope: $scope, + // set: 'jobevents', + // iterator: 'jobevent', + // url: $scope.current_url + // }); }; } JobEventsList.$inject = ['$sce', '$filter', '$scope', '$rootScope', '$location', '$log', '$stateParams', 'Rest', 'Alert', 'JobEventList', - 'generateList', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', - 'GetBasePath', 'LookUpInit', 'ToggleChildren', 'FormatDate', 'EventView', 'Refresh', 'Wait' + 'generateList', 'Prompt', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', + 'GetBasePath', 'LookUpInit', 'ToggleChildren', 'FormatDate', 'EventView', 'Wait' ]; export function JobEventsEdit($scope, $rootScope, $compile, $location, $log, $stateParams, JobEventsForm, GenerateForm, diff --git a/awx/ui/client/src/controllers/JobHosts.js b/awx/ui/client/src/controllers/JobHosts.js index cd6c0c65a4..e078bc4df7 100644 --- a/awx/ui/client/src/controllers/JobHosts.js +++ b/awx/ui/client/src/controllers/JobHosts.js @@ -12,13 +12,14 @@ export function JobHostSummaryList($scope, $rootScope, $location, $log, $stateParams, Rest, Alert, JobHostList, GenerateList, - Prompt, SearchInit, PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, Refresh, + Prompt, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, JobStatusToolTip) { ClearScope(); var list = JobHostList, - defaultUrl = GetBasePath('jobs') + $stateParams.id + '/job_host_summaries/', + // @issue: OLD SEARCH + // defaultUrl = GetBasePath('jobs') + $stateParams.id + '/job_host_summaries/', view = GenerateList, inventory; @@ -58,26 +59,27 @@ export function JobHostSummaryList($scope, $rootScope, $location, $log, $statePa $scope.removeJobReady = $scope.$on('JobReady', function() { view.inject(list, { mode: 'edit', scope: $scope }); - SearchInit({ - scope: $scope, - set: 'jobhosts', - list: list, - url: defaultUrl - }); - - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - - // Called from Inventories tab, host failed events link: - if ($stateParams.host_name) { - $scope[list.iterator + 'SearchField'] = 'host'; - $scope[list.iterator + 'SearchValue'] = $stateParams.host_name; - $scope[list.iterator + 'SearchFieldLabel'] = list.fields.host.label; - } - $scope.search(list.iterator); + // @issue: OLD SEARCH + // SearchInit({ + // scope: $scope, + // set: 'jobhosts', + // list: list, + // url: defaultUrl + // }); + // + // PaginateInit({ + // scope: $scope, + // list: list, + // url: defaultUrl + // }); + // + // // Called from Inventories tab, host failed events link: + // if ($stateParams.host_name) { + // $scope[list.iterator + 'SearchField'] = 'host'; + // $scope[list.iterator + 'SearchValue'] = $stateParams.host_name; + // $scope[list.iterator + 'SearchFieldLabel'] = list.fields.host.label; + // } + // $scope.search(list.iterator); }); Rest.setUrl(GetBasePath('jobs') + $scope.job_id); @@ -107,12 +109,13 @@ export function JobHostSummaryList($scope, $rootScope, $location, $log, $statePa }; $scope.refresh = function () { - $scope.search(list.iterator); + // @issue: OLD SEARCH + // $scope.search(list.iterator); }; } JobHostSummaryList.$inject = ['$scope', '$rootScope', '$location', '$log', '$stateParams', 'Rest', 'Alert', 'JobHostList', - 'generateList', 'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', - 'GetBasePath', 'Refresh', 'JobStatusToolTip', 'Wait' + 'generateList', 'Prompt', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', + 'GetBasePath', 'JobStatusToolTip', 'Wait' ]; diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js index 07e84df3ee..54187896df 100644 --- a/awx/ui/client/src/controllers/Jobs.js +++ b/awx/ui/client/src/controllers/Jobs.js @@ -8,148 +8,105 @@ * @ngdoc function * @name controllers.function:Jobs * @description This controller's for the jobs page -*/ + */ -export function JobsListController ($rootScope, $log, $scope, $compile, $stateParams, - ClearScope, LoadSchedulesScope, - LoadJobsScope, AllJobsList, ScheduledJobsList, GetChoices, GetBasePath, Wait, $state) { + +export function JobsListController($state, $rootScope, $log, $scope, $compile, $stateParams, + ClearScope, Find, DeleteJob, RelaunchJob, AllJobsList, ScheduledJobsList, GetBasePath, Dataset) { ClearScope(); - var jobs_scope, scheduled_scope, - choicesCount = 0, - listCount = 0, - api_complete = false, - scheduledJobsList = _.cloneDeep(ScheduledJobsList); + var list = AllJobsList; - $scope.jobsSelected = true; + init(); - if ($scope.removeListLoaded) { - $scope.removeListLoaded(); + function init() { + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + $scope.showJobType = true; + + _.forEach($scope[list.name], buildTooltips); } - $scope.removeListLoaded = $scope.$on('listLoaded', function() { - listCount++; - if (listCount === 2) { - api_complete = true; - } - }); - - - // After all choices are ready, load up the lists and populate the page - if ($scope.removeBuildJobsList) { - $scope.removeBuildJobsList(); + function buildTooltips(job) { + job.status_tip = 'Job ' + job.status + ". Click for details."; } - $scope.removeBuildJobsList = $scope.$on('buildJobsList', function() { - var opt, search_params={}; - if (AllJobsList.fields.type) { - AllJobsList.fields.type.searchOptions = $scope.type_choices; - } - if ($stateParams.status) { - search_params[AllJobsList.iterator + 'SearchField'] = 'status'; - search_params[AllJobsList.iterator + 'SelectShow'] = true; - search_params[AllJobsList.iterator + 'SearchSelectOpts'] = AllJobsList.fields.status.searchOptions; - search_params[AllJobsList.iterator + 'SearchFieldLabel'] = AllJobsList.fields.status.label.replace(//g,' '); - search_params[AllJobsList.iterator + 'SearchType'] = ''; - for (opt in AllJobsList.fields.status.searchOptions) { - if (AllJobsList.fields.status.searchOptions[opt].value === $stateParams.status) { - search_params[AllJobsList.iterator + 'SearchSelectValue'] = AllJobsList.fields.status.searchOptions[opt]; - break; - } - } - } - jobs_scope = $scope.$new(true); + $scope.deleteJob = function(id) { + DeleteJob({ scope: $scope, id: id }); + }; - jobs_scope.viewJob = function (id) { - $state.transitionTo('jobDetail', {id: id}); + $scope.relaunchJob = function(event, id) { + var list, job, typeId; + try { + $(event.target).tooltip('hide'); + } catch (e) { + //ignore + } + + job = Find({ list: list, key: 'id', val: id }); + if (job.type === 'inventory_update') { + typeId = job.inventory_source; + } else if (job.type === 'project_update') { + typeId = job.project; + } else if (job.type === 'job' || job.type === "system_job" || job.type === 'ad_hoc_command') { + typeId = job.id; + } + RelaunchJob({ scope: $scope, id: typeId, type: job.type, name: job.name }); + }; + + $scope.refreshJobs = function() { + $state.go('.', null, { reload: true }); + }; + + $scope.viewJobDetails = function(job) { + + var goToJobDetails = function(state) { + $state.go(state, { id: job.id }, { reload: true }); }; - - jobs_scope.showJobType = true; - LoadJobsScope({ - parent_scope: $scope, - scope: jobs_scope, - list: AllJobsList, - id: 'active-jobs', - url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled,new&order_by=-finished', - pageSize: 20, - searchParams: search_params, - spinner: false - }); - - - scheduled_scope = $scope.$new(true); - scheduledJobsList.basePath = GetBasePath('schedules') + '?next_run__isnull=false'; - LoadSchedulesScope({ - parent_scope: $scope, - scope: scheduled_scope, - list: scheduledJobsList, - pageSize: 20, - id: 'scheduled-jobs-tab', - searchSize: 'col-lg-4 col-md-4 col-sm-4 col-xs-12', - url: scheduledJobsList.basePath - }); - - $scope.refreshJobs = function() { - jobs_scope.search('all_job'); - scheduled_scope.search('schedule'); - }; - - function clearTabs() { - $scope.jobsSelected = false; - $scope.schedulesSelected = false; - } - - $scope.toggleTab = function(tab) { - clearTabs(); - if (tab === "jobs") { - $scope.jobsSelected = true; - } else if (tab === "scheduled") { - $scope.schedulesSelected = true; - } - }; - - $scope.$on('ws-jobs', function() { - $scope.refreshJobs(); - }); - - $scope.$on('ws-schedules', function() { - if (api_complete) { - scheduled_scope.search('schedule'); - } - }); - }); - - if ($scope.removeChoicesReady) { - $scope.removeChoicesReady(); - } - $scope.removeChoicesReady = $scope.$on('choicesReady', function() { - choicesCount++; - if (choicesCount === 2) { - $scope.$emit('buildJobsList'); + switch (job.type) { + case 'job': + goToJobDetails('jobDetail'); + break; + case 'ad_hoc_command': + goToJobDetails('adHocJobStdout'); + break; + case 'system_job': + goToJobDetails('managementJobStdout'); + break; + case 'project_update': + goToJobDetails('scmUpdateStdout'); + break; + case 'inventory_update': + goToJobDetails('inventorySyncStdout'); + break; } + + }; + + $scope.refreshJobs = function() { + $state.reload(); + }; + + if ($rootScope.removeJobStatusChange) { + $rootScope.removeJobStatusChange(); + } + $rootScope.removeJobStatusChange = $rootScope.$on('JobStatusChange-jobs', function() { + $scope.refreshJobs(); }); - Wait('start'); - - GetChoices({ - scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'status', - variable: 'status_choices', - callback: 'choicesReady' - }); - - GetChoices({ - scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'type', - variable: 'type_choices', - callback: 'choicesReady' + if ($rootScope.removeScheduleStatusChange) { + $rootScope.removeScheduleStatusChange(); + } + $rootScope.removeScheduleStatusChange = $rootScope.$on('ScheduleStatusChange', function() { + $state.reload(); }); } -JobsListController.$inject = ['$rootScope', '$log', '$scope', '$compile', '$stateParams', -'ClearScope', 'LoadSchedulesScope', 'LoadJobsScope', -'AllJobsList', 'ScheduledJobsList', 'GetChoices', 'GetBasePath', 'Wait', '$state']; +JobsListController.$inject = ['$state', '$rootScope', '$log', '$scope', '$compile', '$stateParams', + 'ClearScope', 'Find', 'DeleteJob', 'RelaunchJob', 'AllJobsList', 'ScheduledJobsList', 'GetBasePath', 'Dataset' +]; diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 832e1bd2e3..380f472a97 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -8,85 +8,64 @@ * @ngdoc function * @name controllers.function:Projects * @description This controller's for the projects page -*/ + */ -export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, - Rest, Alert, ProjectList, GenerateList, Prompt, SearchInit, - PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, - SelectionInit, ProjectUpdate, Refresh, Wait, GetChoices, Empty, - Find, GetProjectIcon, GetProjectToolTip, $filter, $state, rbacUiControlService, - i18n) { - ClearScope(); - - $scope.canAdd = false; - - rbacUiControlService.canAdd('projects') - .then(function(canAdd) { - $scope.canAdd = canAdd; - }); - - Wait('start'); +export function ProjectsList($scope, $rootScope, $location, $log, $stateParams, + Rest, Alert, ProjectList, Prompt, ReturnToCaller, ClearScope, ProcessErrors, + GetBasePath, ProjectUpdate, Wait, GetChoices, Empty, Find, GetProjectIcon, + GetProjectToolTip, $filter, $state, rbacUiControlService, Dataset, i18n) { var list = ProjectList, - defaultUrl = GetBasePath('projects') + ($stateParams.status ? '?status__in=' + $stateParams.status : ''), - view = GenerateList, - base = $location.path().replace(/^\//, '').split('/')[0], - mode = (base === 'projects') ? 'edit' : 'select', - url = (base === 'teams') ? GetBasePath('teams') + $stateParams.team_id + '/projects/' : defaultUrl, - choiceCount = 0; - view.inject(list, { mode: mode, scope: $scope }); + defaultUrl = GetBasePath('projects'); - $rootScope.flashMessage = null; - $scope.projectLoading = true; + init(); - if (mode === 'select') { - SelectionInit({ - scope: $scope, - list: list, - url: url, - returnToCaller: 1 - }); - } + function init() { + $scope.canAdd = false; - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - Wait('stop'); - if ($scope.projects) { - $scope.projects.forEach(function(project, i) { - $scope.projects[i].statusIcon = GetProjectIcon(project.status); - $scope.projects[i].statusTip = GetProjectToolTip(project.status); - $scope.projects[i].scm_update_tooltip = i18n._("Start an SCM update"); - $scope.projects[i].scm_schedule_tooltip = i18n._("Schedule future SCM updates"); - $scope.projects[i].scm_type_class = ""; - - if (project.status === 'failed' && project.summary_fields.last_update && project.summary_fields.last_update.status === 'canceled') { - $scope.projects[i].statusTip = i18n._('Canceled. Click for details'); - } - - if (project.status === 'running' || project.status === 'updating') { - $scope.projects[i].scm_update_tooltip = i18n._("SCM update currently running"); - $scope.projects[i].scm_type_class = "btn-disabled"; - } - - $scope.project_scm_type_options.forEach(function(type) { - if (type.value === project.scm_type) { - $scope.projects[i].scm_type = type.label; - if (type.label === 'Manual') { - $scope.projects[i].scm_update_tooltip = i18n._('Manual projects do not require an SCM update'); - $scope.projects[i].scm_schedule_tooltip = i18n._('Manual projects do not require a schedule'); - $scope.projects[i].scm_type_class = 'btn-disabled'; - $scope.projects[i].statusTip = i18n._('Not configured for SCM'); - $scope.projects[i].statusIcon = 'none'; - } - } - }); + rbacUiControlService.canAdd('projects') + .then(function(canAdd) { + $scope.canAdd = canAdd; }); - } + + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + _.forEach($scope[list.name], buildTooltips); + $rootScope.flashMessage = null; + } + + $scope.$watch(`${list.name}`, function() { + _.forEach($scope[list.name], buildTooltips); }); + function buildTooltips(project) { + project.statusIcon = GetProjectIcon(project.status); + project.statusTip = GetProjectToolTip(project.status); + project.scm_update_tooltip = "Start an SCM update"; + project.scm_schedule_tooltip = i18n._("Schedule future SCM updates"); + project.scm_type_class = ""; + + if (project.status === 'failed' && project.summary_fields.last_update && project.summary_fields.last_update.status === 'canceled') { + project.statusTip = i18n._('Canceled. Click for details'); + } + + if (project.status === 'running' || project.status === 'updating') { + project.scm_update_tooltip = i18n._("SCM update currently running"); + project.scm_type_class = "btn-disabled"; + } + if (project.scm_type === 'manual') { + project.scm_update_tooltip = i18n._('Manual projects do not require an SCM update'); + project.scm_schedule_tooltip = i18n._('Manual projects do not require a schedule'); + project.scm_type_class = 'btn-disabled'; + project.statusTip = i18n._('Not configured for SCM'); + project.statusIcon = 'none'; + } + } + $scope.$on(`ws-jobs`, function(e, data) { var project; $log.debug(data); @@ -98,9 +77,9 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, $log.debug('Received event for project: ' + project.name); $log.debug('Status changed to: ' + data.status); if (data.status === 'successful' || data.status === 'failed') { - $scope.search(list.iterator, null, null, null, null, false); - } - else { + // @issue: OLD SEARCH + // $scope.search(list.iterator, null, null, null, null, false); + } else { project.scm_update_tooltip = "SCM update currently running"; project.scm_type_class = "btn-disabled"; } @@ -111,95 +90,12 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, } }); - if ($scope.removeChoicesHere) { - $scope.removeChoicesHere(); - } - $scope.removeChoicesHere = $scope.$on('choicesCompleteProjectList', function () { - var opt; - - list.fields.scm_type.searchOptions = $scope.project_scm_type_options; - list.fields.status.searchOptions = $scope.project_status_options; - - if ($stateParams.scm_type && $stateParams.status) { - // Request coming from home page. User wants all errors for an scm_type - defaultUrl += '?status=' + $stateParams.status; - } - - SearchInit({ - scope: $scope, - set: 'projects', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - - if ($stateParams.scm_type) { - $scope[list.iterator + 'SearchType'] = ''; - $scope[list.iterator + 'SearchField'] = 'scm_type'; - $scope[list.iterator + 'SelectShow'] = true; - $scope[list.iterator + 'SearchSelectOpts'] = list.fields.scm_type.searchOptions; - $scope[list.iterator + 'SearchFieldLabel'] = list.fields.scm_type.label.replace(//g, ' '); - for (opt in list.fields.scm_type.searchOptions) { - if (list.fields.scm_type.searchOptions[opt].value === $stateParams.scm_type) { - $scope[list.iterator + 'SearchSelectValue'] = list.fields.scm_type.searchOptions[opt]; - break; - } - } - } else if ($stateParams.status) { - $scope[list.iterator + 'SearchType'] = ''; - $scope[list.iterator + 'SearchValue'] = $stateParams.status; - $scope[list.iterator + 'SearchField'] = 'status'; - $scope[list.iterator + 'SelectShow'] = true; - $scope[list.iterator + 'SearchFieldLabel'] = list.fields.status.label; - $scope[list.iterator + 'SearchSelectOpts'] = list.fields.status.searchOptions; - for (opt in list.fields.status.searchOptions) { - if (list.fields.status.searchOptions[opt].value === $stateParams.status) { - $scope[list.iterator + 'SearchSelectValue'] = list.fields.status.searchOptions[opt]; - break; - } - } - } - $scope.search(list.iterator); - }); - - if ($scope.removeChoicesReadyList) { - $scope.removeChoicesReadyList(); - } - $scope.removeChoicesReadyList = $scope.$on('choicesReadyProjectList', function () { - choiceCount++; - if (choiceCount === 2) { - $scope.$emit('choicesCompleteProjectList'); - } - }); - - // Load options for status --used in search - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'status', - variable: 'project_status_options', - callback: 'choicesReadyProjectList' - }); - - // Load the list of options for Kind - GetChoices({ - scope: $scope, - url: defaultUrl, - field: 'scm_type', - variable: 'project_scm_type_options', - callback: 'choicesReadyProjectList' - }); - - $scope.addProject = function () { - $state.transitionTo('projects.add'); + $scope.addProject = function() { + $state.go('projects.add'); }; - $scope.editProject = function (id) { - $state.transitionTo('projects.edit', {id: id}); + $scope.editProject = function(id) { + $state.go('projects.edit', { project_id: id }); }; if ($scope.removeGoToJobDetails) { @@ -213,7 +109,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, // Grab the id from summary_fields var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('scmUpdateStdout', {id: id}); + $state.go('scmUpdateStdout', { id: id }); } else { Alert(i18n._('No Updates Available'), i18n._('There is no SCM update information available for this project. An update has not yet been ' + @@ -221,7 +117,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, } }); - $scope.showSCMStatus = function (id) { + $scope.showSCMStatus = function(id) { // Refresh the project list var project = Find({ list: $scope.projects, key: 'id', val: id }); if (Empty(project.scm_type) || project.scm_type === 'Manual') { @@ -241,18 +137,19 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, } }; - $scope.deleteProject = function (id, name) { - var action = function () { + $scope.deleteProject = function(id, name) { + var action = function() { $('#prompt-modal').modal('hide'); Wait('start'); var url = defaultUrl + id + '/'; Rest.setUrl(url); Rest.destroy() - .success(function () { - if (parseInt($state.params.id) === id) { - $state.go("^", null, {reload: true}); + .success(function() { + if (parseInt($state.params.project_id) === id) { + $state.go("^", null, { reload: true }); } else { - $scope.search(list.iterator); + // @issue: OLD SEARCH + // $scope.search(list.iterator); } }) .error(function (data, status) { @@ -272,7 +169,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, if ($scope.removeCancelUpdate) { $scope.removeCancelUpdate(); } - $scope.removeCancelUpdate = $scope.$on('Cancel_Update', function (e, url) { + $scope.removeCancelUpdate = $scope.$on('Cancel_Update', function(e, url) { // Cancel the project update process Rest.setUrl(url); Rest.post() @@ -288,12 +185,12 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, if ($scope.removeCheckCancel) { $scope.removeCheckCancel(); } - $scope.removeCheckCancel = $scope.$on('Check_Cancel', function (e, data) { + $scope.removeCheckCancel = $scope.$on('Check_Cancel', function(e, data) { // Check that we 'can' cancel the update var url = data.related.cancel; Rest.setUrl(url); Rest.get() - .success(function (data) { + .success(function(data) { if (data.can_cancel) { $scope.$emit('Cancel_Update', url); } else { @@ -306,14 +203,14 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, }); }); - $scope.cancelUpdate = function (id, name) { + $scope.cancelUpdate = function(id, name) { Rest.setUrl(GetBasePath("projects") + id); Rest.get() - .success(function (data) { + .success(function(data) { if (data.related.current_update) { Rest.setUrl(data.related.current_update); Rest.get() - .success(function (data) { + .success(function(data) { $scope.$emit('Check_Cancel', data); }) .error(function (data, status) { @@ -331,15 +228,10 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, }); }; - $scope.refresh = function () { - $scope.search(list.iterator); - }; - - $scope.SCMUpdate = function (project_id, event) { + $scope.SCMUpdate = function(project_id, event) { try { $(event.target).tooltip('hide'); - } - catch(e) { + } catch (e) { // ignore } $scope.projects.every(function(project) { @@ -362,62 +254,52 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams, $scope.editSchedules = function(id) { var project = Find({ list: $scope.projects, key: 'id', val: id }); if (!(project.scm_type === "Manual" || Empty(project.scm_type)) && !(project.status === 'updating' || project.status === 'running' || project.status === 'pending')) { - $state.go('projectSchedules', {id: id}); + $state.go('projectSchedules', { id: id }); } }; } -ProjectsList.$inject = ['$scope', '$rootScope', '$location', '$log', - '$stateParams', 'Rest', 'Alert', 'ProjectList', 'generateList', 'Prompt', - 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', - 'ProcessErrors', 'GetBasePath', 'SelectionInit', 'ProjectUpdate', - 'Refresh', 'Wait', 'GetChoices', 'Empty', 'Find', - 'GetProjectIcon', 'GetProjectToolTip', '$filter', '$state', 'rbacUiControlService', - 'i18n' +ProjectsList.$inject = ['$scope', '$rootScope', '$location', '$log', '$stateParams', + 'Rest', 'Alert', 'ProjectList', 'Prompt', 'ReturnToCaller', 'ClearScope', 'ProcessErrors', + 'GetBasePath', 'ProjectUpdate', 'Wait', 'GetChoices', 'Empty', 'Find', 'GetProjectIcon', + 'GetProjectToolTip', '$filter', '$state', 'rbacUiControlService', 'Dataset', 'i18n' ]; +export function ProjectsAdd($scope, $rootScope, $compile, $location, $log, + $stateParams, GenerateForm, ProjectsForm, Rest, Alert, ProcessErrors, + GetBasePath, GetProjectPath, GetChoices, Wait, $state, CreateSelect2) { -export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $log, - $stateParams, ProjectsForm, GenerateForm, Rest, Alert, ProcessErrors, - ClearScope, GetBasePath, ReturnToCaller, GetProjectPath, LookUpInit, - OrganizationList, CredentialList, GetChoices, DebugForm, Wait, $state, - CreateSelect2, i18n) { - - Rest.setUrl(GetBasePath('projects')); - Rest.options() - .success(function(data) { - if (!data.actions.POST) { - $state.go("^"); - Alert('Permission Error', 'You do not have permission to add a project.', 'alert-info'); - } - }); - - ClearScope(); - - // Inject dynamic view var form = ProjectsForm(), - generator = GenerateForm, base = $location.path().replace(/^\//, '').split('/')[0], defaultUrl = GetBasePath('projects'), master = {}; - // remove "type" field from search options - CredentialList = _.cloneDeep(CredentialList); - CredentialList.fields.kind.noSearch = true; + init(); - generator.inject(form, { mode: 'add', related: false, scope: $scope }); - generator.reset(); + function init() { + Rest.setUrl(GetBasePath('projects')); + Rest.options() + .success(function(data) { + if (!data.actions.POST) { + $state.go("^"); + Alert('Permission Error', 'You do not have permission to add a project.', 'alert-info'); + } + }); + + // apply form definition's default field values + GenerateForm.applyDefaults(form, $scope); + } GetProjectPath({ scope: $scope, master: master }); if ($scope.removeChoicesReady) { $scope.removeChoicesReady(); } - $scope.removeChoicesReady = $scope.$on('choicesReady', function () { + $scope.removeChoicesReady = $scope.$on('choicesReady', function() { var i; for (i = 0; i < $scope.scm_type_options.length; i++) { if ($scope.scm_type_options[i].value === '') { - $scope.scm_type_options[i].value="manual"; + $scope.scm_type_options[i].value = "manual"; //$scope.scm_type = $scope.scm_type_options[i]; break; } @@ -440,33 +322,14 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l variable: 'scm_type_options', callback: 'choicesReady' }); - - LookUpInit({ - scope: $scope, - form: form, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); - - LookUpInit({ - scope: $scope, - url: GetBasePath('credentials') + '?kind=scm', - form: form, - list: CredentialList, - field: 'credential', - input_type: "radio" - }); - CreateSelect2({ element: '#local-path-select', multiple: false }); // Save - $scope.formSave = function () { - var i, fld, url, data={}; - generator.clearApiErrors(); + $scope.formSave = function() { + var i, fld, url, data = {}; data = {}; for (fld in form.fields) { if (form.fields[fld].type === 'checkbox_group') { @@ -480,8 +343,8 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l } } - if($scope.scm_type.value === "manual"){ - data.scm_type = "" ; + if ($scope.scm_type.value === "manual") { + data.scm_type = ""; data.local_path = $scope.local_path.value; } else { data.scm_type = $scope.scm_type.value; @@ -492,26 +355,18 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l Wait('start'); Rest.setUrl(url); Rest.post(data) - .success(function (data) { + .success(function(data) { $scope.addedItem = data.id; - - Refresh({ - scope: $scope, - set: 'projects', - iterator: 'project', - url: $scope.current_url - }); - - $state.go('projects.edit', {id: data.id}, {reload: true}); + $state.go('projects.edit', { id: data.id }, { reload: true }); }) - .error(function (data, status) { + .error(function(data, status) { Wait('stop'); ProcessErrors($scope, data, status, form, { hdr: i18n._('Error!'), msg: i18n._('Failed to create new project. POST returned status: ') + status }); }); }; - $scope.scmChange = function () { + $scope.scmChange = function() { // When an scm_type is set, path is not required if ($scope.scm_type) { $scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false; @@ -520,7 +375,7 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l } // Dynamically update popover values - if($scope.scm_type.value) { + if ($scope.scm_type.value) { switch ($scope.scm_type.value) { case 'git': $scope.urlPopover = i18n._('

Example URLs for GIT SCM include:

  • https://github.com/ansible/ansible.git
  • ' + @@ -528,7 +383,7 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l '

    Note: When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' + 'do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using ' + 'SSH. GIT read only protocol (git://) does not use username or password information.'); - break; + break; case 'svn': $scope.urlPopover = i18n._('

    Example URLs for Subversion SCM include:

    ' + '
    • https://github.com/ansible/ansible
    • svn://servername.example.com/path
    • ' + @@ -548,89 +403,47 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l } }; - - $scope.formCancel = function () { - $state.transitionTo('projects'); + $scope.formCancel = function() { + $state.go('projects'); }; } -ProjectsAdd.$inject = ['Refresh', '$scope', '$rootScope', '$compile', '$location', '$log', - '$stateParams', 'ProjectsForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'ClearScope', 'GetBasePath', 'ReturnToCaller', - 'GetProjectPath', 'LookUpInit', 'OrganizationList', 'CredentialList', - 'GetChoices', 'DebugForm', 'Wait', '$state', 'CreateSelect2', 'i18n' -]; +ProjectsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', + '$stateParams', 'GenerateForm', 'ProjectsForm', 'Rest', 'Alert', 'ProcessErrors', 'GetBasePath', + 'GetProjectPath', 'GetChoices', 'Wait', '$state', 'CreateSelect2', 'i18n']; export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, - $stateParams, ProjectsForm, GenerateForm, Rest, Alert, ProcessErrors, - RelatedSearchInit, RelatedPaginateInit, Prompt, ClearScope, GetBasePath, - ReturnToCaller, GetProjectPath, Authorization, CredentialList, LookUpInit, - GetChoices, Empty, DebugForm, Wait, SchedulesControllerInit, - SchedulesListInit, SchedulesList, ProjectUpdate, $state, CreateSelect2, - OrganizationList, NotificationsListInit, ToggleNotification, i18n) { + $stateParams, ProjectsForm, Rest, Alert, ProcessErrors, + Prompt, ClearScope, GetBasePath, GetProjectPath, Authorization, + GetChoices, Empty, DebugForm, Wait, ProjectUpdate, $state, CreateSelect2, ToggleNotification, i18n) { ClearScope('htmlTemplate'); + var form = ProjectsForm(), + defaultUrl = GetBasePath('projects') + $stateParams.project_id + '/', + master = {}, + id = $stateParams.project_id; + + init(); + + function init() { + $scope.project_local_paths = []; + $scope.base_dir = ''; + } + $scope.$watch('project_obj.summary_fields.user_capabilities.edit', function(val) { if (val === false) { $scope.canAdd = false; } }); - // Inject dynamic view - var form = ProjectsForm(), - generator = GenerateForm, - defaultUrl = GetBasePath('projects') + $stateParams.id + '/', - base = $location.path().replace(/^\//, '').split('/')[0], - master = {}, i, - id = $stateParams.id, - relatedSets = {}; - - // remove "type" field from search options - CredentialList = _.cloneDeep(CredentialList); - CredentialList.fields.kind.noSearch = true; - - - SchedulesList.well = false; - generator.inject(form, { - mode: 'edit', - related: true, - scope: $scope - }); - generator.reset(); - - $scope.project_local_paths = []; - $scope.base_dir = ''; - - if ($scope.removerelatedschedules) { - $scope.removerelatedschedules(); - } - $scope.removerelatedschedules = $scope.$on('relatedschedules', function() { - SchedulesListInit({ - scope: $scope, - list: SchedulesList, - choices: null, - related: true - }); - }); - // After the project is loaded, retrieve each related set if ($scope.projectLoadedRemove) { $scope.projectLoadedRemove(); } - $scope.projectLoadedRemove = $scope.$on('projectLoaded', function () { - var set, opts=[]; - - for (set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - - SchedulesControllerInit({ - scope: $scope, - parent_scope: $scope, - iterator: 'schedule' - }); + $scope.projectLoadedRemove = $scope.$on('projectLoaded', function() { + var opts = []; if (Authorization.getUserInfo('is_superuser') === true) { GetProjectPath({ scope: $scope, master: master }); @@ -644,42 +457,19 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, $scope.base_dir = 'You do not have access to view this property'; } - LookUpInit({ - url: GetBasePath('credentials') + '?kind=scm', - scope: $scope, - form: form, - list: CredentialList, - field: 'credential', - input_type: 'radio' - }); - - LookUpInit({ - scope: $scope, - form: form, - current_item: $scope.organization, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); - $scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false; $scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false; $scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch'; Wait('stop'); - NotificationsListInit({ - scope: $scope, - url: GetBasePath('projects'), - id: $scope.project_obj.id - }); - $scope.scmChange(); }); if ($scope.removeChoicesReady) { $scope.removeChoicesReady(); } - $scope.removeChoicesReady = $scope.$on('choicesReady', function () { + $scope.removeChoicesReady = $scope.$on('choicesReady', function() { + let i; for (i = 0; i < $scope.scm_type_options.length; i++) { if ($scope.scm_type_options[i].value === '') { $scope.scm_type_options[i].value = "manual"; @@ -689,7 +479,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, // Retrieve detail record and prepopulate the form Rest.setUrl(defaultUrl); Rest.get({ params: { id: id } }) - .success(function (data) { + .success(function(data) { var fld, i; for (fld in form.fields) { if (form.fields[fld].type === 'checkbox_group') { @@ -705,15 +495,12 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, } if (form.fields[fld].sourceModel && data.summary_fields && data.summary_fields[form.fields[fld].sourceModel]) { - $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] = + $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] = data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; master[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] = data.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; } } - relatedSets = form.relatedSets(data.related); - - data.scm_type = (Empty(data.scm_type)) ? 'manual' : data.scm_type; for (i = 0; i < $scope.scm_type_options.length; i++) { @@ -743,18 +530,6 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, }); $scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch'; - - // Initialize related search functions. Doing it here to make sure relatedSets object is populated. - RelatedSearchInit({ - scope: $scope, - form: form, - relatedSets: relatedSets - }); - RelatedPaginateInit({ - scope: $scope, - relatedSets: relatedSets - }); - $scope.scm_update_tooltip = "Start an SCM update"; $scope.scm_type_class = ""; if (data.status === 'running' || data.status === 'updating') { @@ -791,14 +566,12 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, var notifier = this.notification; try { $(event.target).tooltip('hide'); - } - catch(e) { + } catch (e) { // ignore } ToggleNotification({ scope: $scope, - url: $scope.project_url, - id: $scope.project_obj.id, + url: $scope.project_obj.url, notifier: notifier, column: column, callback: 'NotificationRefresh' @@ -806,9 +579,9 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, }; // Save changes to the parent - $scope.formSave = function () { + $scope.formSave = function() { var fld, i, params; - generator.clearApiErrors(); + //generator.clearApiErrors(); Wait('start'); $rootScope.flashMessage = null; params = {}; @@ -824,8 +597,8 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, } } - if($scope.scm_type.value === "manual"){ - params.scm_type = "" ; + if ($scope.scm_type.value === "manual") { + params.scm_type = ""; params.local_path = $scope.local_path.value; } else { params.scm_type = $scope.scm_type.value; @@ -836,37 +609,26 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, Rest.put(params) .success(function() { Wait('stop'); - $state.go($state.current, {}, {reload: true}); + $state.go($state.current, {}, { reload: true }); }) - .error(function (data, status) { + .error(function(data, status) { ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to update project: ' + id + '. PUT status: ' + status }); }); }; - // Related set: Add button - $scope.add = function (set) { - $rootScope.flashMessage = null; - $location.path('/' + base + '/' + $stateParams.id + '/' + set); - }; - - // Related set: Edit button - $scope.edit = function (set, id) { - $rootScope.flashMessage = null; - $location.path('/' + set + '/' + id); - }; - // Related set: Delete button - $scope['delete'] = function (set, itm_id, name, title) { - var action = function () { + $scope['delete'] = function(set, itm_id, name, title) { + var action = function() { var url = GetBasePath('projects') + id + '/' + set + '/'; $rootScope.flashMessage = null; Rest.setUrl(url); Rest.post({ id: itm_id, disassociate: 1 }) - .success(function () { + .success(function() { $('#prompt-modal').modal('hide'); - $scope.search(form.related[set].iterator); + // @issue: OLD SEARCH + // $scope.search(form.related[set].iterator); }) - .error(function (data, status) { + .error(function(data, status) { $('#prompt-modal').modal('hide'); ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Call to ' + url + ' failed. POST returned status: ' + status }); }); @@ -880,7 +642,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, }); }; - $scope.scmChange = function () { + $scope.scmChange = function() { if ($scope.scm_type) { $scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false; $scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false; @@ -888,7 +650,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, } // Dynamically update popover values - if($scope.scm_type.value) { + if ($scope.scm_type.value) { switch ($scope.scm_type.value) { case 'git': $scope.urlPopover = i18n._('

      Example URLs for GIT SCM include:

      • https://github.com/ansible/ansible.git
      • ' + @@ -896,7 +658,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, '

        Note: When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' + 'do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using ' + 'SSH. GIT read only protocol (git://) does not use username or password information.'); - break; + break; case 'svn': $scope.urlPopover = i18n._('

        Example URLs for Subversion SCM include:

        ' + '
        • https://github.com/ansible/ansible
        • svn://servername.example.com/path
        • ' + @@ -916,7 +678,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, } }; - $scope.SCMUpdate = function () { + $scope.SCMUpdate = function() { if ($scope.project_obj.scm_type === "Manual" || Empty($scope.project_obj.scm_type)) { // ignore } else if ($scope.project_obj.status === 'updating' || $scope.project_obj.status === 'running' || $scope.project_obj.status === 'pending') { @@ -926,17 +688,12 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, } }; - $scope.formCancel = function () { + $scope.formCancel = function() { $state.transitionTo('projects'); }; } ProjectsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', - '$stateParams', 'ProjectsForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', 'Prompt', - 'ClearScope', 'GetBasePath', 'ReturnToCaller', 'GetProjectPath', - 'Authorization', 'CredentialList', 'LookUpInit', 'GetChoices', 'Empty', - 'DebugForm', 'Wait', 'SchedulesControllerInit', 'SchedulesListInit', - 'SchedulesList', 'ProjectUpdate', '$state', 'CreateSelect2', - 'OrganizationList', 'NotificationsListInit', 'ToggleNotification', 'i18n' -]; + '$stateParams', 'ProjectsForm', 'Rest', 'Alert', 'ProcessErrors', 'Prompt', + 'ClearScope', 'GetBasePath', 'GetProjectPath', 'Authorization', 'GetChoices', 'Empty', + 'DebugForm', 'Wait', 'ProjectUpdate', '$state', 'CreateSelect2', 'ToggleNotification', 'i18n']; diff --git a/awx/ui/client/src/controllers/Schedules.js b/awx/ui/client/src/controllers/Schedules.js index 88f4129122..edebf66a84 100644 --- a/awx/ui/client/src/controllers/Schedules.js +++ b/awx/ui/client/src/controllers/Schedules.js @@ -18,33 +18,33 @@ GetBasePath, Wait, Find, LoadSchedulesScope, GetChoices) { var base, id, url, parentObject; - base = $location.path().replace(/^\//, '').split('/')[0]; + // base = $location.path().replace(/^\//, '').split('/')[0]; - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function() { - var list = $scope.schedules; - list.forEach(function(element, idx) { - list[idx].play_tip = (element.enabled) ? 'Schedule is Active. Click to temporarily stop.' : 'Schedule is temporarily stopped. Click to activate.'; - }); - }); + // if ($scope.removePostRefresh) { + // $scope.removePostRefresh(); + // } + // $scope.removePostRefresh = $scope.$on('PostRefresh', function() { + // var list = $scope.schedules; + // list.forEach(function(element, idx) { + // list[idx].play_tip = (element.enabled) ? 'Schedule is Active. Click to temporarily stop.' : 'Schedule is temporarily stopped. Click to activate.'; + // }); + // }); - if ($scope.removeParentLoaded) { - $scope.removeParentLoaded(); - } - $scope.removeParentLoaded = $scope.$on('ParentLoaded', function() { - url += "schedules/"; - SchedulesList.well = true; - LoadSchedulesScope({ - parent_scope: $scope, - scope: $scope, - list: SchedulesList, - id: 'schedule-list-target', - url: url, - pageSize: 20 - }); - }); + // if ($scope.removeParentLoaded) { + // $scope.removeParentLoaded(); + // } + // $scope.removeParentLoaded = $scope.$on('ParentLoaded', function() { + // url += "schedules/"; + // SchedulesList.well = true; + // LoadSchedulesScope({ + // parent_scope: $scope, + // scope: $scope, + // list: SchedulesList, + // id: 'schedule-list-target', + // url: url, + // pageSize: 20 + // }); + // }); if ($scope.removeChoicesReady) { @@ -67,7 +67,8 @@ GetBasePath, Wait, Find, LoadSchedulesScope, GetChoices) { }); $scope.refreshJobs = function() { - $scope.search(SchedulesList.iterator); + // @issue: OLD SEARCH + // $scope.search(SchedulesList.iterator); }; Wait('start'); diff --git a/awx/ui/client/src/controllers/Teams.js b/awx/ui/client/src/controllers/Teams.js index aded8e5824..b4ae59fb39 100644 --- a/awx/ui/client/src/controllers/Teams.js +++ b/awx/ui/client/src/controllers/Teams.js @@ -8,108 +8,62 @@ * @ngdoc function * @name controllers.function:Teams * @description This controller's for teams -*/ + */ +export function TeamsList($scope, $rootScope, $log, $stateParams, + Rest, Alert, TeamList, Prompt, ClearScope, ProcessErrors, + GetBasePath, Wait, $state, $filter, rbacUiControlService, Dataset) { -export function TeamsList($scope, $rootScope, $location, $log, $stateParams, - Rest, Alert, TeamList, GenerateList, Prompt, SearchInit, PaginateInit, - ReturnToCaller, ClearScope, ProcessErrors, SetTeamListeners, GetBasePath, - SelectionInit, Wait, $state, Refresh, $filter, rbacUiControlService) { ClearScope(); - $scope.canAdd = false; - - rbacUiControlService.canAdd('teams') - .then(function(canAdd) { - $scope.canAdd = canAdd; - }); - var list = TeamList, - defaultUrl = GetBasePath('teams'), - generator = GenerateList, - paths = $location.path().replace(/^\//, '').split('/'), - mode = (paths[0] === 'teams') ? 'edit' : 'select', - url; + defaultUrl = GetBasePath('teams'); - var injectForm = function() { - generator.inject(list, { mode: mode, scope: $scope }); - }; + init(); - injectForm(); + function init() { + $scope.canAdd = false; - $scope.$on("RefreshTeamsList", function() { - injectForm(); - Refresh({ - scope: $scope, - set: 'teams', - iterator: 'team', - url: GetBasePath('teams') + "?order_by=name&page_size=" + $scope.team_page_size + rbacUiControlService.canAdd('teams') + .then(function(canAdd) { + $scope.canAdd = canAdd; + }); + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + _.forEach($scope[list.name], (team) => { + team.organization_name = team.summary_fields.organization.name; }); - }); - $scope.selected = []; - - url = GetBasePath('base') + $location.path() + '/'; - SelectionInit({ - scope: $scope, - list: list, - url: url, - returnToCaller: 1 - }); - - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); + $scope.selected = []; } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - // After a refresh, populate the organization name on each row - var i; - if ($scope.teams) { - for (i = 0; i < $scope.teams.length; i++) { - if ($scope.teams[i].summary_fields.organization) { - $scope.teams[i].organization_name = $scope.teams[i].summary_fields.organization.name; - } - } - } - }); - SearchInit({ - scope: $scope, - set: 'teams', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - $scope.search(list.iterator); - - $scope.addTeam = function () { - $state.transitionTo('teams.add'); + $scope.addTeam = function() { + $state.go('teams.add'); }; - $scope.editTeam = function (id) { - $state.transitionTo('teams.edit', {team_id: id}); + $scope.editTeam = function(id) { + $state.go('teams.edit', { team_id: id }); }; - $scope.deleteTeam = function (id, name) { + $scope.deleteTeam = function(id, name) { - var action = function () { + var action = function() { Wait('start'); var url = defaultUrl + id + '/'; Rest.setUrl(url); Rest.destroy() - .success(function () { + .success(function() { Wait('stop'); $('#prompt-modal').modal('hide'); if (parseInt($state.params.team_id) === id) { - $state.go("^", null, {reload: true}); + $state.go('^', null, { reload: true }); } else { - $scope.search(list.iterator); + $state.go('.', null, { reload: true }); } }) - .error(function (data, status) { + .error(function(data, status) { Wait('stop'); $('#prompt-modal').modal('hide'); ProcessErrors($scope, data, status, null, { @@ -128,18 +82,15 @@ export function TeamsList($scope, $rootScope, $location, $log, $stateParams, }; } -TeamsList.$inject = ['$scope', '$rootScope', '$location', '$log', - '$stateParams', 'Rest', 'Alert', 'TeamList', 'generateList', 'Prompt', - 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', - 'ProcessErrors', 'SetTeamListeners', 'GetBasePath', 'SelectionInit', 'Wait', - '$state', 'Refresh', '$filter', 'rbacUiControlService' + +TeamsList.$inject = ['$scope', '$rootScope', '$log', + '$stateParams', 'Rest', 'Alert', 'TeamList', 'Prompt', 'ClearScope', + 'ProcessErrors', 'GetBasePath', 'Wait', '$state', '$filter', 'rbacUiControlService', 'Dataset' ]; -export function TeamsAdd($scope, $rootScope, $compile, $location, $log, - $stateParams, TeamForm, GenerateForm, Rest, Alert, ProcessErrors, - ReturnToCaller, ClearScope, GenerateList, OrganizationList, SearchInit, - PaginateInit, GetBasePath, LookUpInit, Wait, $state) { +export function TeamsAdd($scope, $rootScope, $stateParams, TeamForm, GenerateForm, Rest, Alert, ProcessErrors, + ClearScope, GetBasePath, Wait, $state) { ClearScope('htmlTemplate'); //Garbage collection. Don't leave behind any listeners/watchers from the prior //$scope. @@ -155,176 +106,128 @@ export function TeamsAdd($scope, $rootScope, $compile, $location, $log, // Inject dynamic view var defaultUrl = GetBasePath('teams'), form = TeamForm, - generator = GenerateForm, - scope = generator.inject(form, { mode: 'add', related: false }); + generator = GenerateForm; - $rootScope.flashMessage = null; - generator.reset(); + init(); - LookUpInit({ - scope: $scope, - form: form, - current_item: null, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); + function init() { + // apply form definition's default field values + GenerateForm.applyDefaults(form, $scope); + + $rootScope.flashMessage = null; + } // Save - $scope.formSave = function () { + $scope.formSave = function() { var fld, data; generator.clearApiErrors(); Wait('start'); Rest.setUrl(defaultUrl); data = {}; for (fld in form.fields) { - data[fld] = scope[fld]; + data[fld] = $scope[fld]; } Rest.post(data) - .success(function (data) { + .success(function(data) { Wait('stop'); $rootScope.flashMessage = "New team successfully created!"; $rootScope.$broadcast("EditIndicatorChange", "users", data.id); - $state.go('teams.edit', {team_id: data.id}, {reload: true}); + $state.go('teams.edit', { team_id: data.id }, { reload: true }); }) - .error(function (data, status) { + .error(function(data, status) { Wait('stop'); - ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to add new team. Post returned status: ' + - status }); + ProcessErrors($scope, data, status, form, { + hdr: 'Error!', + msg: 'Failed to add new team. Post returned status: ' + + status + }); }); }; - $scope.formCancel = function () { - $state.transitionTo('teams'); + $scope.formCancel = function() { + $state.go('teams'); }; } -TeamsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', - '$stateParams', 'TeamForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'generateList', - 'OrganizationList', 'SearchInit', 'PaginateInit', 'GetBasePath', - 'LookUpInit', 'Wait', '$state' +TeamsAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'GenerateForm', + 'Rest', 'Alert', 'ProcessErrors', 'ClearScope', 'GetBasePath', 'Wait', '$state' ]; -export function TeamsEdit($scope, $rootScope, $location, - $stateParams, TeamForm, GenerateForm, Rest, ProcessErrors, - RelatedSearchInit, RelatedPaginateInit, ClearScope, - LookUpInit, GetBasePath, OrganizationList, Wait, $state) { +export function TeamsEdit($scope, $rootScope, $stateParams, + TeamForm, Rest, ProcessErrors, ClearScope, GetBasePath, Wait, $state) { ClearScope(); - var defaultUrl = GetBasePath('teams'), - generator = GenerateForm, - form = TeamForm, + var form = TeamForm, id = $stateParams.team_id, - relatedSets = {}, - set; + defaultUrl = GetBasePath('teams') + id; - $scope.team_id = id; + init(); - $scope.$watch('team_obj.summary_fields.user_capabilities.edit', function(val) { - if (val === false) { - $scope.canAdd = false; - } - }); + function init() { + $scope.team_id = id; + Rest.setUrl(defaultUrl); + Wait('start'); + Rest.get(defaultUrl).success(function(data) { + setScopeFields(data); + $scope.organization_name = data.summary_fields.organization.name; + + $scope.team_obj = data; + }); + + $scope.$watch('team_obj.summary_fields.user_capabilities.edit', function(val) { + if (val === false) { + $scope.canAdd = false; + } + }); - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - generator.reset(); + } - var setScopeFields = function(data){ + // @issue I think all this really want to do is _.forEach(form.fields, (field) =>{ $scope[field] = data[field]}) + function setScopeFields(data) { _(data) - .pick(function(value, key){ - return form.fields.hasOwnProperty(key) === true; - }) - .forEach(function(value, key){ - $scope[key] = value; - }) - .value(); + .pick(function(value, key) { + return form.fields.hasOwnProperty(key) === true; + }) + .forEach(function(value, key) { + $scope[key] = value; + }) + .value(); return; - }; - var setScopeRelated = function(data, related){ - _(related) - .pick(function(value, key){ - return data.related.hasOwnProperty(key) === true; - }) - .forEach(function(value, key){ - relatedSets[key] = { - url: data.related[key], - iterator: value.iterator - }; - }) - .value(); - }; + } // prepares a data payload for a PUT request to the API - var processNewData = function(fields){ + function processNewData(fields) { var data = {}; - _.forEach(fields, function(value, key){ - if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined){ - data[key] = $scope[key]; + _.forEach(fields, function(value, key) { + if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) { + data[key] = $scope[key]; } }); return data; + } + + $scope.formCancel = function() { + $state.go('teams', null, { reload: true }); }; - var init = function(){ - var url = defaultUrl + id; - Rest.setUrl(url); - Wait('start'); - Rest.get(url).success(function(data){ - setScopeFields(data); - setScopeRelated(data, form.related); - $scope.organization_name = data.summary_fields.organization.name; - - RelatedSearchInit({ - scope: $scope, - form: form, - relatedSets: relatedSets - }); - - RelatedPaginateInit({ - scope: $scope, - relatedSets: relatedSets - }); - - for (set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - - $scope.team_obj = data; - - LookUpInit({ - url: GetBasePath('organizations'), - scope: $scope, - form: form, - current_item: $scope.organization, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); - }); - }; - - $scope.formCancel = function(){ - $state.go('teams', null, {reload: true}); - }; - - $scope.formSave = function(){ - generator.clearApiErrors(); - generator.checkAutoFill(); + $scope.formSave = function() { $rootScope.flashMessage = null; - if ($scope[form.name + '_form'].$valid){ + if ($scope[form.name + '_form'].$valid) { Rest.setUrl(defaultUrl + id + '/'); var data = processNewData(form.fields); - Rest.put(data).success(function(){ - $state.go($state.current, null, {reload: true}); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve user: ' + - $stateParams.id + '. GET status: ' + status }); - }); + Rest.put(data).success(function() { + $state.go($state.current, null, { reload: true }); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Failed to retrieve user: ' + + $stateParams.id + '. GET status: ' + status + }); + }); } }; @@ -337,13 +240,8 @@ export function TeamsEdit($scope, $rootScope, $location, return null; } }; - - /* Related Set implementation TDB */ } -TeamsEdit.$inject = ['$scope', '$rootScope', '$location', - '$stateParams', 'TeamForm', 'GenerateForm', 'Rest', - 'ProcessErrors', 'RelatedSearchInit', 'RelatedPaginateInit', - 'ClearScope', 'LookUpInit', 'GetBasePath', - 'OrganizationList', 'Wait', '$state' +TeamsEdit.$inject = ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest', + 'ProcessErrors', 'ClearScope', 'GetBasePath', 'Wait', '$state' ]; diff --git a/awx/ui/client/src/controllers/Users.js b/awx/ui/client/src/controllers/Users.js index ee7ed6f431..3a7f1b86a8 100644 --- a/awx/ui/client/src/controllers/Users.js +++ b/awx/ui/client/src/controllers/Users.js @@ -8,14 +8,14 @@ * @ngdoc function * @name controllers.function:Users * @description This controller's the Users page -*/ + */ -import {N_} from "../i18n"; +import { N_ } from "../i18n"; const user_type_options = [ - {type: 'normal' , label: N_('Normal User') }, - {type: 'system_auditor' , label: N_('System Auditor') }, - {type: 'system_administrator', label: N_('System Administrator') }, + { type: 'normal', label: N_('Normal User') }, + { type: 'system_auditor', label: N_('System Auditor') }, + { type: 'system_administrator', label: N_('System Administrator') }, ]; function user_type_sync($scope) { @@ -25,18 +25,17 @@ function user_type_sync($scope) { switch (type_option.type) { case 'system_administrator': $scope.is_superuser = true; - break; + break; case 'system_auditor': $scope.is_system_auditor = true; - break; + break; } }; } -export function UsersList($scope, $rootScope, $location, $log, $stateParams, - Rest, Alert, UserList, GenerateList, Prompt, SearchInit, PaginateInit, - ReturnToCaller, ClearScope, ProcessErrors, GetBasePath, SelectionInit, - Wait, $state, Refresh, $filter, rbacUiControlService, i18n) { +export function UsersList($scope, $rootScope, $stateParams, + Rest, Alert, UserList, Prompt, ClearScope, ProcessErrors, GetBasePath, + Wait, $state, $filter, rbacUiControlService, Dataset, i18n) { for (var i = 0; i < user_type_options.length; i++) { user_type_options[i].label = i18n._(user_type_options[i].label); @@ -44,95 +43,57 @@ export function UsersList($scope, $rootScope, $location, $log, $stateParams, ClearScope(); - $scope.canAdd = false; - - rbacUiControlService.canAdd('users') - .then(function(canAdd) { - $scope.canAdd = canAdd; - }); - var list = UserList, - defaultUrl = GetBasePath('users'), - generator = GenerateList, - base = $location.path().replace(/^\//, '').split('/')[0], - mode = (base === 'users') ? 'edit' : 'select', - url = (base === 'organizations') ? GetBasePath('organizations') + $stateParams.organization_id + '/users/' : - GetBasePath('teams') + $stateParams.team_id + '/users/'; + defaultUrl = GetBasePath('users'); - var injectForm = function() { - generator.inject(UserList, { mode: mode, scope: $scope }); - }; + init(); - injectForm(); + function init() { + $scope.canAdd = false; - $scope.$on("RefreshUsersList", function() { - injectForm(); - Refresh({ - scope: $scope, - set: 'users', - iterator: 'user', - url: GetBasePath('users') + "?order_by=username&page_size=" + $scope.user_page_size - }); - }); + rbacUiControlService.canAdd('users') + .then(function(canAdd) { + $scope.canAdd = canAdd; + }); - $scope.selected = []; + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - if (mode === 'select') { - SelectionInit({ scope: $scope, list: list, url: url, returnToCaller: 1 }); + + $rootScope.flashMessage = null; + $scope.selected = []; } - if ($scope.removePostRefresh) { - $scope.removePostRefresh(); - } - $scope.removePostRefresh = $scope.$on('PostRefresh', function () { - // Cleanup after a delete - Wait('stop'); - $('#prompt-modal').modal('hide'); - }); - - $rootScope.flashMessage = null; - SearchInit({ - scope: $scope, - set: 'users', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl - }); - $scope.search(list.iterator); - - $scope.addUser = function () { - $state.transitionTo('users.add'); + $scope.addUser = function() { + $state.go('users.add'); }; - $scope.editUser = function (id) { - $state.transitionTo('users.edit', {user_id: id}); + $scope.editUser = function(id) { + $state.go('users.edit', { user_id: id }); }; - $scope.deleteUser = function (id, name) { + $scope.deleteUser = function(id, name) { - var action = function () { - //$('#prompt-modal').on('hidden.bs.modal', function () { - // Wait('start'); - //}); + var action = function() { $('#prompt-modal').modal('hide'); Wait('start'); var url = defaultUrl + id + '/'; Rest.setUrl(url); Rest.destroy() - .success(function () { + .success(function() { if (parseInt($state.params.user_id) === id) { - $state.go("^", null, {reload: true}); + $state.go('^', null, { reload: true }); } else { - $scope.search(list.iterator); + $state.go('.', null, { reload: true }); } }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status }); + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + url + ' failed. DELETE returned status: ' + status + }); }); }; @@ -145,91 +106,54 @@ export function UsersList($scope, $rootScope, $location, $log, $stateParams, }; } -UsersList.$inject = ['$scope', '$rootScope', '$location', '$log', - '$stateParams', 'Rest', 'Alert', 'UserList', 'generateList', 'Prompt', - 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', - 'ProcessErrors', 'GetBasePath', 'SelectionInit', 'Wait', '$state', - 'Refresh', '$filter', 'rbacUiControlService', 'i18n' +UsersList.$inject = ['$scope', '$rootScope', '$stateParams', + 'Rest', 'Alert', 'UserList', 'Prompt', 'ClearScope', 'ProcessErrors', 'GetBasePath', + 'Wait', '$state', '$filter', 'rbacUiControlService', 'Dataset', 'i18n' ]; - - - -export function UsersAdd($scope, $rootScope, $compile, $location, $log, - $stateParams, UserForm, GenerateForm, Rest, Alert, ProcessErrors, - ReturnToCaller, ClearScope, GetBasePath, LookUpInit, OrganizationList, - ResetForm, Wait, CreateSelect2, $state, i18n) { - - for (var i = 0; i < user_type_options.length; i++) { - user_type_options[i].label = i18n._(user_type_options[i].label); - } - - Rest.setUrl(GetBasePath('users')); - Rest.options() - .success(function(data) { - if (!data.actions.POST) { - $state.go("^"); - Alert('Permission Error', 'You do not have permission to add a user.', 'alert-info'); - } - }); +export function UsersAdd($scope, $rootScope, $stateParams, UserForm, + GenerateForm, Rest, Alert, ProcessErrors, ReturnToCaller, ClearScope, + GetBasePath, ResetForm, Wait, CreateSelect2, $state, i18n) { ClearScope(); - // Inject dynamic view var defaultUrl = GetBasePath('organizations'), - form = UserForm, - generator = GenerateForm; + form = UserForm; - generator.inject(form, { mode: 'add', related: false, scope: $scope }); - ResetForm(); + init(); - $scope.ldap_user = false; - $scope.not_ldap_user = !$scope.ldap_user; - $scope.ldap_dn = null; - $scope.socialAuthUser = false; - $scope.external_account = null; + function init() { + // apply form definition's default field values + GenerateForm.applyDefaults(form, $scope); - generator.reset(); + $scope.ldap_user = false; + $scope.not_ldap_user = !$scope.ldap_user; + $scope.ldap_dn = null; + $scope.socialAuthUser = false; + $scope.external_account = null; - $scope.user_type_options = user_type_options; - $scope.user_type = user_type_options[0]; - $scope.$watch('user_type', user_type_sync($scope)); - - CreateSelect2({ - element: '#user_user_type', - multiple: false - }); - - // Configure the lookup dialog. If we're adding a user through the Organizations tab, - // default the Organization value. - LookUpInit({ - scope: $scope, - form: form, - current_item: ($stateParams.organization_id !== undefined) ? $stateParams.organization_id : null, - list: OrganizationList, - field: 'organization', - input_type: 'radio' - }); - - if ($stateParams.organization_id) { - $scope.organization = $stateParams.organization_id; - Rest.setUrl(GetBasePath('organizations') + $stateParams.organization_id + '/'); - Rest.get() - .success(function (data) { - $scope.organization_name = data.name; - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to lookup Organization: ' + data.id + '. GET returned status: ' + status }); + Rest.setUrl(GetBasePath('users')); + Rest.options() + .success(function(data) { + if (!data.actions.POST) { + $state.go("^"); + Alert('Permission Error', 'You do not have permission to add a user.', 'alert-info'); + } }); + + $scope.user_type_options = user_type_options; + $scope.user_type = user_type_options[0]; + $scope.$watch('user_type', user_type_sync($scope)); + CreateSelect2({ + element: '#user_user_type', + multiple: false + }); } // Save - $scope.formSave = function () { + $scope.formSave = function() { var fld, data = {}; - generator.clearApiErrors(); - generator.checkAutoFill(); if ($scope[form.name + '_form'].$valid) { if ($scope.organization !== undefined && $scope.organization !== null && $scope.organization !== '') { Rest.setUrl(defaultUrl + $scope.organization + '/users/'); @@ -244,18 +168,17 @@ export function UsersAdd($scope, $rootScope, $compile, $location, $log, data.is_system_auditor = $scope.is_system_auditor; Wait('start'); Rest.post(data) - .success(function (data) { + .success(function(data) { var base = $location.path().replace(/^\//, '').split('/')[0]; if (base === 'users') { $rootScope.flashMessage = 'New user successfully created!'; $rootScope.$broadcast("EditIndicatorChange", "users", data.id); - $state.go('users.edit', {user_id: data.id}, {reload: true}); - } - else { + $state.go('users.edit', { user_id: data.id }, { reload: true }); + } else { ReturnToCaller(1); } }) - .error(function (data, status) { + .error(function(data, status) { ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: 'Failed to add new user. POST returned status: ' + status }); }); } else { @@ -264,69 +187,102 @@ export function UsersAdd($scope, $rootScope, $compile, $location, $log, } }; - $scope.formCancel = function () { - $state.transitionTo('users'); + $scope.formCancel = function() { + $state.go('users'); }; // Password change - $scope.clearPWConfirm = function (fld) { + $scope.clearPWConfirm = function(fld) { // If password value changes, make sure password_confirm must be re-entered $scope[fld] = ''; $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false); }; } -UsersAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log', - '$stateParams', 'UserForm', 'GenerateForm', 'Rest', 'Alert', - 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'GetBasePath', - 'LookUpInit', 'OrganizationList', 'ResetForm', 'Wait', 'CreateSelect2', '$state', - 'i18n' +UsersAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'UserForm', 'GenerateForm', + 'Rest', 'Alert', 'ProcessErrors', 'ReturnToCaller', 'ClearScope', 'GetBasePath', + 'ResetForm', 'Wait', 'CreateSelect2', '$state', 'i18n' ]; - export function UsersEdit($scope, $rootScope, $location, - $stateParams, UserForm, GenerateForm, Rest, ProcessErrors, - RelatedSearchInit, RelatedPaginateInit, ClearScope, - GetBasePath, ResetForm, Wait, CreateSelect2 ,$state, i18n) { + $stateParams, UserForm, Rest, ProcessErrors, + ClearScope, GetBasePath, ResetForm, Wait, CreateSelect2, $state, i18n) { for (var i = 0; i < user_type_options.length; i++) { user_type_options[i].label = i18n._(user_type_options[i].label); } - ClearScope(); - var defaultUrl = GetBasePath('users'), - generator = GenerateForm, - form = UserForm, + var form = UserForm, master = {}, id = $stateParams.user_id, - relatedSets = {}, - set; + defaultUrl = GetBasePath('users') + id; - generator.inject(form, { mode: 'edit', related: true, scope: $scope }); - generator.reset(); + init(); - $scope.user_type_options = user_type_options; - $scope.user_type = user_type_options[0]; - $scope.$watch('user_type', user_type_sync($scope)); + function init() { + $scope.user_type_options = user_type_options; + $scope.user_type = user_type_options[0]; + $scope.$watch('user_type', user_type_sync($scope)); + Rest.setUrl(defaultUrl); + Wait('start'); + Rest.get(defaultUrl).success(function(data) { + $scope.user_id = id; + $scope.ldap_user = (data.ldap_dn !== null && data.ldap_dn !== undefined && data.ldap_dn !== '') ? true : false; + $scope.not_ldap_user = !$scope.ldap_user; + master.ldap_user = $scope.ldap_user; + $scope.socialAuthUser = (data.auth.length > 0) ? true : false; + $scope.external_account = data.external_account; - $scope.$watch('user_obj.summary_fields.user_capabilities.edit', function(val) { - if (val === false) { - $scope.canAdd = false; - } - }); + $scope.user_type = $scope.user_type_options[0]; + $scope.is_system_auditor = false; + $scope.is_superuser = false; + if (data.is_system_auditor) { + $scope.user_type = $scope.user_type_options[1]; + $scope.is_system_auditor = true; + } + if (data.is_superuser) { + $scope.user_type = $scope.user_type_options[2]; + $scope.is_superuser = true; + } - var setScopeFields = function(data){ + $scope.user_obj = data; + + CreateSelect2({ + element: '#user_user_type', + multiple: false + }); + + $scope.$watch('user_obj.summary_fields.user_capabilities.edit', function(val) { + if (val === false) { + $scope.canAdd = false; + } + }); + + setScopeFields(data); + Wait('stop'); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Failed to retrieve user: ' + + $stateParams.id + '. GET status: ' + status + }); + }); + } + + + function setScopeFields(data) { _(data) - .pick(function(value, key){ - return form.fields.hasOwnProperty(key) === true; - }) - .forEach(function(value, key){ - $scope[key] = value; - }) - .value(); + .pick(function(value, key) { + return form.fields.hasOwnProperty(key) === true; + }) + .forEach(function(value, key) { + $scope[key] = value; + }) + .value(); return; - }; + } $scope.convertApiUrl = function(str) { if (str) { @@ -336,25 +292,12 @@ export function UsersEdit($scope, $rootScope, $location, } }; - var setScopeRelated = function(data, related){ - _(related) - .pick(function(value, key){ - return data.related.hasOwnProperty(key) === true; - }) - .forEach(function(value, key){ - relatedSets[key] = { - url: data.related[key], - iterator: value.iterator - }; - }) - .value(); - }; // prepares a data payload for a PUT request to the API - var processNewData = function(fields){ + var processNewData = function(fields) { var data = {}; - _.forEach(fields, function(value, key){ - if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined){ - data[key] = $scope[key]; + _.forEach(fields, function(value, key) { + if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) { + data[key] = $scope[key]; } }); data.is_superuser = $scope.is_superuser; @@ -362,98 +305,37 @@ export function UsersEdit($scope, $rootScope, $location, return data; }; - var init = function(){ - var url = defaultUrl + id; - Rest.setUrl(url); - Wait('start'); - Rest.get(url).success(function(data){ - $scope.user_id = id; - $scope.ldap_user = (data.ldap_dn !== null && data.ldap_dn !== undefined && data.ldap_dn !== '') ? true : false; - $scope.not_ldap_user = !$scope.ldap_user; - master.ldap_user = $scope.ldap_user; - $scope.socialAuthUser = (data.auth.length > 0) ? true : false; - $scope.external_account = data.external_account; - - $scope.user_type = $scope.user_type_options[0]; - $scope.is_system_auditor = false; - $scope.is_superuser = false; - if (data.is_system_auditor) { - $scope.user_type = $scope.user_type_options[1]; - $scope.is_system_auditor = true; - } - if (data.is_superuser) { - $scope.user_type = $scope.user_type_options[2]; - $scope.is_superuser = true; - } - - $scope.user_obj = data; - - CreateSelect2({ - element: '#user_user_type', - multiple: false - }); - - - setScopeFields(data); - setScopeRelated(data, form.related); - - RelatedSearchInit({ - scope: $scope, - form: form, - relatedSets: relatedSets - }); - RelatedPaginateInit({ - scope: $scope, - relatedSets: relatedSets - }); - - for (set in relatedSets) { - $scope.search(relatedSets[set].iterator); - } - - Wait('stop'); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve user: ' + - $stateParams.id + '. GET status: ' + status }); - }); + $scope.formCancel = function() { + $state.go('users', null, { reload: true }); }; - $scope.formCancel = function(){ - $state.go('users', null, {reload: true}); - }; - - $scope.formSave = function(){ - generator.clearApiErrors(); - generator.checkAutoFill(); + $scope.formSave = function() { $rootScope.flashMessage = null; - if ($scope[form.name + '_form'].$valid){ + if ($scope[form.name + '_form'].$valid) { Rest.setUrl(defaultUrl + id + '/'); var data = processNewData(form.fields); - Rest.put(data).success(function(){ - $state.go($state.current, null, {reload: true}); - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', msg: 'Failed to retrieve user: ' + - $stateParams.id + '. GET status: ' + status }); - }); + Rest.put(data).success(function() { + $state.go($state.current, null, { reload: true }); + }) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Failed to retrieve user: ' + + $stateParams.id + '. GET status: ' + status + }); + }); } }; - $scope.clearPWConfirm = function (fld) { + $scope.clearPWConfirm = function(fld) { // If password value changes, make sure password_confirm must be re-entered $scope[fld] = ''; $scope[form.name + '_form'][fld].$setValidity('awpassmatch', false); $rootScope.flashMessage = null; }; - - init(); - - /* Related Set implementation TDB */ } UsersEdit.$inject = ['$scope', '$rootScope', '$location', - '$stateParams', 'UserForm', 'GenerateForm', 'Rest', 'ProcessErrors', - 'RelatedSearchInit', 'RelatedPaginateInit', 'ClearScope', 'GetBasePath', + '$stateParams', 'UserForm', 'Rest', 'ProcessErrors', 'ClearScope', 'GetBasePath', 'ResetForm', 'Wait', 'CreateSelect2', '$state', 'i18n' ]; diff --git a/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js b/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js index 246ccb3a68..8d26b9d1c6 100644 --- a/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js +++ b/awx/ui/client/src/dashboard/counts/dashboard-counts.directive.js @@ -39,7 +39,7 @@ export default label: i18n._("Hosts") }, { - url: "/#/home/hosts?active-failures=true", + url: "/#/home/hosts?host_search=has_active_failures:true", number: scope.data.hosts.failed, label: i18n._("Failed Hosts"), isFailureCount: true @@ -50,7 +50,7 @@ export default label: i18n._("Inventories"), }, { - url: "/#/inventories?status=sync-failed", + url: "/#/inventories?inventory_search=inventory_sources_with_failures__gt:0", number: scope.data.inventories.inventory_failed, label: i18n._("Inventory Sync Failures"), isFailureCount: true @@ -61,7 +61,7 @@ export default label: i18n._("Projects") }, { - url: "/#/projects?status=failed,canceled", + url: "/#/projects?project_search=status__in:failed,canceled", number: scope.data.projects.failed, label: i18n._("Project Sync Failures"), isFailureCount: true diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.partial.html b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.partial.html deleted file mode 100644 index 5db1583d13..0000000000 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-edit.partial.html +++ /dev/null @@ -1,4 +0,0 @@ -
          -
          -
          -
          diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js index 9009efe609..d07c544942 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.controller.js @@ -4,86 +4,52 @@ * All Rights Reserved *************************************************/ -export default - ['$scope', '$state', '$stateParams', 'PageRangeSetup', 'GetBasePath', 'DashboardHostsList', - 'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', 'SearchInit', - function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope, SearchInit){ - var setJobStatus = function(){ - _.forEach($scope.hosts, function(value){ - SetStatus({ - scope: $scope, - host: value - }); - }); - }; - var generator = GenerateList, - list = DashboardHostsList, - defaultUrl = GetBasePath('hosts'); - $scope.hostPageSize = 10; - $scope.editHost = function(id){ - $state.go('dashboardHosts.edit', {id: id}); - }; - $scope.toggleHostEnabled = function(host){ - DashboardHostService.setHostStatus(host, !host.enabled) - .then(function(res){ - var index = _.findIndex($scope.hosts, function(o) {return o.id === res.data.id;}); - $scope.hosts[index].enabled = res.data.enabled; - }); - }; - $scope.$on('PostRefresh', function(){ - $scope.hosts = _.map($scope.hosts, function(value){ - value.inventory_name = value.summary_fields.inventory.name; - value.inventory_id = value.summary_fields.inventory.id; - return value; - }); - setJobStatus(); - }); - var cleanUpStateChangeListener = $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams) { - if (toState.name === "dashboardHosts.edit") { - $scope.rowBeingEdited = toParams.id; - $scope.listBeingEdited = "hosts"; - } - else { - delete $scope.rowBeingEdited; - delete $scope.listBeingEdited; - } - }); - // Remove the listener when the scope is destroyed to avoid a memory leak - $scope.$on('$destroy', function() { - cleanUpStateChangeListener(); - }); - var init = function(){ - $scope.list = list; - $scope.host_active_search = false; - $scope.host_total_rows = hosts.results.length; - $scope.hosts = hosts.results; - setJobStatus(); - generator.inject(list, {mode: 'edit', scope: $scope}); - SearchInit({ - scope: $scope, - set: 'hosts', - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: $scope, - list: list, - url: defaultUrl, - pageSize: 10 - }); - PageRangeSetup({ - scope: $scope, - count: hosts.count, - next: hosts.next, - previous: hosts.previous, - iterator: list.iterator +export default ['$scope', '$state', '$stateParams', 'GetBasePath', 'DashboardHostsList', + 'generateList', 'SetStatus', 'DashboardHostService', '$rootScope', 'Dataset', + function($scope, $state, $stateParams, GetBasePath, DashboardHostsList, + GenerateList, SetStatus, DashboardHostService, $rootScope, Dataset) { + + let list = DashboardHostsList; + init(); + + function init() { + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + + $scope.$watchCollection(list.name, function() { + $scope[list.name] = _.map($scope.hosts, function(value) { + value.inventory_name = value.summary_fields.inventory.name; + value.inventory_id = value.summary_fields.inventory.id; + return value; + }); + setJobStatus(); }); - $scope.hostLoading = false; - if($state.current.name === "dashboardHosts.edit") { - $scope.rowBeingEdited = $state.params.id; - $scope.listBeingEdited = "hosts"; - } - $scope.search(list.iterator); - }; - init(); - }]; + } + + + function setJobStatus(){ + _.forEach($scope.hosts, function(value) { + SetStatus({ + scope: $scope, + host: value + }); + }); + } + + $scope.editHost = function(id) { + $state.go('dashboardHosts.edit', { id: id }); + }; + + $scope.toggleHostEnabled = function(host) { + DashboardHostService.setHostStatus(host, !host.enabled) + .then(function(res) { + var index = _.findIndex($scope.hosts, function(o) { + return o.id === res.data.id; + }); + $scope.hosts[index].enabled = res.data.enabled; + }); + }; + } +]; diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.partial.html b/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.partial.html deleted file mode 100644 index 53511acd75..0000000000 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts-list.partial.html +++ /dev/null @@ -1,4 +0,0 @@ -
          -
          -
          -
          \ No newline at end of file diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.form.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.form.js index 50b2e7a2c0..6af1326980 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.form.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.form.js @@ -18,7 +18,7 @@ export default function(){ class: 'Form-header-field', ngClick: 'toggleHostEnabled()', type: 'toggle', - editRequired: false, + awToolTip: "

          Indicates if a host is available and should be included in running jobs.

          For hosts that " + "are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.

          ", dataTitle: 'Host Enabled' @@ -28,7 +28,7 @@ export default function(){ name: { label: 'Host Name', type: 'text', - editRequired: true, + value: '{{name}}', awPopOver: "

          Provide a host name, ip address, or ip address:port. Examples include:

          " + "
          myserver.domain.com
          " + @@ -43,12 +43,10 @@ export default function(){ description: { label: 'Description', type: 'text', - editRequired: false }, variables: { label: 'Variables', type: 'textarea', - editRequired: false, rows: 6, class: 'modal-input-xlarge Form-textArea Form-formGroup--fullWidth', dataTitle: 'Host Variables', diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js index eb312af291..0c3c0adb9d 100644 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js +++ b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.list.js @@ -21,11 +21,7 @@ export default [ 'i18n', function(i18n){ basePath: 'unified_jobs', label: '', iconOnly: true, - searchable: false, - searchType: 'select', nosort: true, - searchOptions: [], - searchLabel: 'Job Status', icon: 'icon-job-{{ host.active_failures }}', awToolTip: '{{ host.badgeToolTip }}', awTipPlacement: 'right', @@ -54,24 +50,9 @@ export default [ 'i18n', function(i18n){ columnClass: 'List-staticColumn--toggle', type: 'toggle', ngClick: 'toggleHostEnabled(host)', - searchable: false, nosort: true, awToolTip: "

          Indicates if a host is available and should be included in running jobs.

          For hosts that are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.

          ", dataTitle: 'Host Enabled', - }, - has_active_failures: { - label: 'Has failed jobs?', - searchSingleValue: true, - searchType: 'boolean', - searchValue: 'true', - searchOnly: true - }, - has_inventory_sources: { - label: 'Has external source?', - searchSingleValue: true, - searchType: 'boolean', - searchValue: 'true', - searchOnly: true } }, diff --git a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.route.js b/awx/ui/client/src/dashboard/hosts/dashboard-hosts.route.js deleted file mode 100644 index 4af54c92bb..0000000000 --- a/awx/ui/client/src/dashboard/hosts/dashboard-hosts.route.js +++ /dev/null @@ -1,61 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import {templateUrl} from '../../shared/template-url/template-url.factory'; -import listController from './dashboard-hosts-list.controller'; -import editController from './dashboard-hosts-edit.controller'; - -var dashboardHostsList = { - name: 'dashboardHosts', - url: '/home/hosts?:active-failures', - controller: listController, - templateUrl: templateUrl('dashboard/hosts/dashboard-hosts-list'), - data: { - activityStream: true, - activityStreamTarget: 'host' - }, - ncyBreadcrumb: { - parent: 'dashboard', - label: "HOSTS" - }, - resolve: { - hosts: ['Rest', 'GetBasePath', '$stateParams', function(Rest, GetBasePath, $stateParams){ - var defaultUrl = GetBasePath('hosts') + '?page_size=10' + ($stateParams['active-failures'] ? '&has_active_failures=true' : '' ); - Rest.setUrl(defaultUrl); - return Rest.get().then(function(res){ - var results = _.map(res.data.results, function(value){ - value.inventory_name = value.summary_fields.inventory.name; - value.inventory_id = value.summary_fields.inventory.id; - return value; - }); - res.data.results = results; - return res.data; - }); - }] - } -}; - -var dashboardHostsEdit = { - name: 'dashboardHosts.edit', - url: '/:id', - controller: editController, - templateUrl: templateUrl('dashboard/hosts/dashboard-hosts-edit'), - ncyBreadcrumb: { - parent: 'dashboardHosts', - label: "{{host.name}}" - }, - resolve: { - host: ['$stateParams', 'Rest', 'GetBasePath', function($stateParams, Rest, GetBasePath){ - var defaultUrl = GetBasePath('hosts') + '?id=' + $stateParams.id; - Rest.setUrl(defaultUrl); - return Rest.get().then(function(res){ - return res.data.results[0]; - }); - }] - } -}; - -export {dashboardHostsList, dashboardHostsEdit}; diff --git a/awx/ui/client/src/dashboard/hosts/main.js b/awx/ui/client/src/dashboard/hosts/main.js index 7a02e597d0..935dec2d49 100644 --- a/awx/ui/client/src/dashboard/hosts/main.js +++ b/awx/ui/client/src/dashboard/hosts/main.js @@ -4,17 +4,43 @@ * All Rights Reserved *************************************************/ -import {dashboardHostsList, dashboardHostsEdit} from './dashboard-hosts.route'; import list from './dashboard-hosts.list'; import form from './dashboard-hosts.form'; +import listController from './dashboard-hosts-list.controller'; +import editController from './dashboard-hosts-edit.controller'; import service from './dashboard-hosts.service'; export default - angular.module('dashboardHosts', []) +angular.module('dashboardHosts', []) .service('DashboardHostService', service) .factory('DashboardHostsList', list) .factory('DashboardHostsForm', form) - .run(['$stateExtender', function($stateExtender){ - $stateExtender.addState(dashboardHostsList); - $stateExtender.addState(dashboardHostsEdit); - }]); + .config(['$stateProvider', 'stateDefinitionsProvider', + function($stateProvider, stateDefinitionsProvider) { + let stateDefinitions = stateDefinitionsProvider.$get(); + + $stateProvider.state({ + name: 'dashboardHosts', + url: '/home/hosts', + lazyLoad: () => stateDefinitions.generateTree({ + url: '/home/hosts', + parent: 'dashboardHosts', + modes: ['edit'], + list: 'DashboardHostsList', + form: 'DashboardHostsForm', + controllers: { + list: listController, + edit: editController + }, + data: { + activityStream: true, + activityStreamTarget: 'host' + }, + ncyBreadcrumb: { + parent: 'dashboard', + label: "HOSTS" + }, + }) + }); + } + ]); diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index eb4a61b923..8f82f75d55 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -18,6 +18,8 @@ export default addTitle: i18n._('Create Credential'), //Legend in add mode editTitle: '{{ name }}', //Legend in edit mode name: 'credential', + // the top-most node of generated state tree + stateTree: 'credentials', forceListeners: true, subFormTitles: { credentialSubForm: i18n._('Type Details'), @@ -31,24 +33,22 @@ export default name: { label: i18n._('Name'), type: 'text', - addRequired: true, - editRequired: true, + required: true, autocomplete: false, - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, description: { label: i18n._('Description'), type: 'text', - addRequired: false, - editRequired: false, - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, organization: { - addRequired: false, - editRequired: false, + // interpolated with $rootScope + basePath: "{{$rootScope.current_user.is_superuser ? 'api/v1/organizations' : $rootScope.current_user.url + 'admin_of_organizations'}}", ngShow: 'canShareCredential', label: i18n._('Organization'), type: 'lookup', + list: 'OrganizationList', sourceModel: 'organization', sourceField: 'name', ngClick: 'lookUpOrganization()', @@ -56,7 +56,7 @@ export default dataTitle: i18n._('Organization') + ' ', dataPlacement: 'bottom', dataContainer: "body", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, kind: { label: i18n._('Type'), @@ -64,8 +64,7 @@ export default type: 'select', ngOptions: 'kind.label for kind in credential_kind_options track by kind.value', // select as label for value in array 'kind.label for kind in credential_kind_options', ngChange: 'kindChange()', - addRequired: true, - editRequired: true, + required: true, awPopOver: i18n._('
          \n' + '
          Machine
          \n' + '
          Authentication for remote machine access. This can include SSH keys, usernames, passwords, ' + @@ -88,7 +87,7 @@ export default dataPlacement: 'right', dataContainer: "body", hasSubForm: true, - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, access_key: { label: i18n._('Access Key'), @@ -101,7 +100,7 @@ export default autocomplete: false, apiField: 'username', subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, secret_key: { label: i18n._('Secret Key'), @@ -130,7 +129,7 @@ export default dataPlacement: 'right', dataContainer: "body", subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "host": { labelBind: 'hostLabel', @@ -147,7 +146,7 @@ export default init: false }, subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "subscription": { label: i18n._("Subscription ID"), @@ -157,15 +156,15 @@ export default reqExpression: 'subscription_required', init: false }, - addRequired: false, - editRequired: false, + + autocomplete: false, awPopOver: i18n._('

          Subscription ID is an Azure construct, which is mapped to a username.

          '), dataTitle: i18n._('Subscription ID'), dataPlacement: 'right', dataContainer: "body", subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "username": { labelBind: 'usernameLabel', @@ -178,7 +177,7 @@ export default }, autocomplete: false, subForm: "credentialSubForm", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "email_address": { labelBind: 'usernameLabel', @@ -194,7 +193,7 @@ export default dataPlacement: 'right', dataContainer: "body", subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "api_key": { label: i18n._('API Key'), @@ -208,7 +207,7 @@ export default hasShowInputButton: true, clear: false, subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "password": { labelBind: 'passwordLabel', @@ -222,15 +221,13 @@ export default init: false }, subForm: "credentialSubForm", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "ssh_password": { label: i18n._('Password'), type: 'sensitive', ngShow: "kind.value == 'ssh'", ngDisabled: "ssh_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", - addRequired: false, - editRequired: false, subCheckbox: { variable: 'ssh_password_ask', text: i18n._('Ask at runtime?'), @@ -251,8 +248,8 @@ export default }, class: 'Form-textAreaLabel Form-formGroup--fullWidth', elementClass: 'Form-monospace', - addRequired: false, - editRequired: false, + + awDropFile: true, rows: 10, awPopOver: i18n._("SSH key description"), @@ -261,14 +258,12 @@ export default dataPlacement: 'right', dataContainer: "body", subForm: "credentialSubForm", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "ssh_key_unlock": { label: i18n._('Private Key Passphrase'), type: 'sensitive', ngShow: "kind.value == 'ssh' || kind.value == 'scm'", - addRequired: false, - editRequired: false, ngDisabled: "keyEntered === false || ssh_key_unlock_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", subCheckbox: { variable: 'ssh_key_unlock_ask', @@ -293,25 +288,23 @@ export default dataPlacement: 'right', dataContainer: "body", subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "become_username": { labelBind: 'becomeUsernameLabel', type: 'text', ngShow: "(kind.value == 'ssh' && (become_method && become_method.value)) ", - addRequired: false, - editRequired: false, + + autocomplete: false, subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "become_password": { labelBind: 'becomePasswordLabel', type: 'sensitive', ngShow: "(kind.value == 'ssh' && (become_method && become_method.value)) ", ngDisabled: "become_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", - addRequired: false, - editRequired: false, subCheckbox: { variable: 'become_password_ask', text: i18n._('Ask at runtime?'), @@ -326,7 +319,7 @@ export default label: i18n._('Client ID'), subForm: 'credentialSubForm', ngShow: "kind.value === 'azure_rm'", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, secret:{ type: 'sensitive', @@ -335,14 +328,14 @@ export default label: i18n._('Client Secret'), subForm: 'credentialSubForm', ngShow: "kind.value === 'azure_rm'", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, tenant: { type: 'text', label: i18n._('Tenant ID'), subForm: 'credentialSubForm', ngShow: "kind.value === 'azure_rm'", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, authorize: { label: i18n._('Authorize'), @@ -350,7 +343,7 @@ export default ngChange: "toggleCallback('host_config_key')", subForm: 'credentialSubForm', ngShow: "kind.value === 'net'", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, authorize_password: { label: i18n._('Authorize Password'), @@ -359,7 +352,7 @@ export default autocomplete: false, subForm: 'credentialSubForm', ngShow: "authorize && authorize !== 'false'", - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "project": { labelBind: 'projectLabel', @@ -370,14 +363,12 @@ export default dataTitle: i18n._('Project Name'), dataPlacement: 'right', dataContainer: "body", - addRequired: false, - editRequired: false, awRequiredWhen: { reqExpression: 'project_required', init: false }, subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, "domain": { labelBind: 'domainLabel', @@ -391,18 +382,14 @@ export default dataTitle: i18n._('Domain Name'), dataPlacement: 'right', dataContainer: "body", - addRequired: false, - editRequired: false, - subForm: 'credentialSubForm', - ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)', + subForm: 'credentialSubForm' }, "vault_password": { label: i18n._("Vault Password"), type: 'sensitive', ngShow: "kind.value == 'ssh'", ngDisabled: "vault_password_ask || !(credential_obj.summary_fields.user_capabilities.edit || canAdd)", - addRequired: false, - editRequired: false, subCheckbox: { variable: 'vault_password_ask', text: i18n._('Ask at runtime?'), @@ -417,17 +404,17 @@ export default buttons: { cancel: { ngClick: 'formCancel()', - ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, close: { ngClick: 'formCancel()', - ngShow: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '!(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' }, save: { label: 'Save', ngClick: 'formSave()', //$scope.function to call on click, optional ngDisabled: true, - ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' //Disable when $pristine or $invalid, optional + ngShow: '(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' //Disable when $pristine or $invalid, optional } }, @@ -437,24 +424,25 @@ export default awToolTip: '{{permissionsTooltip}}', dataTipWatch: 'permissionsTooltip', dataPlacement: 'top', - basePath: 'credentials/:id/access_list/', + basePath: 'api/v1/credentials/{{$stateParams.credential_id}}/access_list/', + search: { + order_by: 'username' + }, type: 'collection', title: i18n._('Permissions'), iterator: 'permission', index: false, open: false, - searchType: 'select', actions: { add: { - ngClick: "addPermission", + ngClick: "$state.go('.add')", label: 'Add', awToolTip: i18n._('Add a permission'), actionClass: 'btn List-buttonSubmit', buttonContent: i18n._('+ ADD'), - ngShow: '(credential_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(credential_obj.summary_fields.user_capabilities.edit || !canAdd)' } }, - fields: { username: { key: true, diff --git a/awx/ui/client/src/forms/Groups.js b/awx/ui/client/src/forms/Groups.js index 32723ff5f2..60ad2d39a1 100644 --- a/awx/ui/client/src/forms/Groups.js +++ b/awx/ui/client/src/forms/Groups.js @@ -18,31 +18,31 @@ export default editTitle: '{{ name }}', showTitle: true, name: 'group', + basePath: 'groups', + // the parent node this generated state definition tree expects to attach to + stateTree: 'inventoryManage', + // form generator inspects the current state name to determine whether or not to set an active (.is-selected) class on a form tab + // this setting is optional on most forms, except where the form's edit state name is not parentStateName.edit + activeEditState: 'inventoryManage.editGroup', well: false, - fields: { name: { label: 'Name', type: 'text', - addRequired: true, - editRequired: true, - tab: 'properties', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '(!group_obj.summary_fields.user_capabilities.edit || !canAdd)', + required: true, + tab: 'properties' }, description: { label: 'Description', type: 'text', - addRequired: false, - editRequired: false, - tab: 'properties', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '(!group_obj.summary_fields.user_capabilities.edit || !canAdd)', + tab: 'properties' }, variables: { label: 'Variables', type: 'textarea', class: 'Form-textAreaLabel Form-formGroup--fullWidth', - addRequired: false, - editRequird: false, rows: 12, 'default': '---', dataTitle: 'Group Variables', @@ -65,23 +65,23 @@ export default type: 'select', ngOptions: 'source.label for source in source_type_options track by source.value', ngChange: 'sourceChange(source)', - addRequired: false, - editRequired: false, - ngModel: 'source', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '(!group_obj.summary_fields.user_capabilities.edit || !canAdd)', + ngModel: 'source' }, credential: { label: 'Cloud Credential', type: 'lookup', + list: 'CredentialList', + basePath: 'credentials', ngShow: "source && source.value !== '' && source.value !== 'custom'", sourceModel: 'credential', sourceField: 'name', - ngClick: 'lookUpCredential()', + ngClick: 'lookupCredential()', awRequiredWhen: { reqExpression: "cloudCredentialRequired", init: "false" }, - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '(!group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, source_regions: { label: 'Regions', @@ -89,22 +89,20 @@ export default ngOptions: 'source.label for source in source_region_choices track by source.value', multiSelect: true, ngShow: "source && (source.value == 'rax' || source.value == 'ec2' || source.value == 'gce' || source.value == 'azure' || source.value == 'azure_rm')", - addRequired: false, - editRequired: false, + + dataTitle: 'Source Regions', dataPlacement: 'right', awPopOver: "

          Click on the regions field to see a list of regions for your cloud provider. You can select multiple regions, " + "or choose All to include all regions. Tower will only be updated with Hosts associated with the selected regions." + "

          ", dataContainer: 'body', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, instance_filters: { label: 'Instance Filters', type: 'text', ngShow: "source && source.value == 'ec2'", - addRequired: false, - editRequired: false, dataTitle: 'Instance Filters', dataPlacement: 'right', awPopOver: "

          Provide a comma-separated list of filter expressions. " + @@ -118,15 +116,13 @@ export default "

          View the Describe Instances documentation " + "for a complete list of supported filters.

          ", dataContainer: 'body', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, group_by: { label: 'Only Group By', type: 'select', ngShow: "source && source.value == 'ec2'", ngOptions: 'source.label for source in group_by_choices track by source.value', - addRequired: false, - editRequired: false, multiSelect: true, dataTitle: 'Only Group By', dataPlacement: 'right', @@ -144,19 +140,19 @@ export default "
        • Tag None: tags » tag_none
        • " + "

        If blank, all groups above are created except Instance ID.

        ", dataContainer: 'body', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, inventory_script: { label : "Custom Inventory Script", type: 'lookup', + basePath: 'inventory_scripts', + list: 'InventoryScriptList', ngShow: "source && source.value === 'custom'", sourceModel: 'inventory_script', sourceField: 'name', ngClick: 'lookUpInventory_script()' , - addRequired: true, - editRequired: true, ngRequired: "source && source.value === 'custom'", - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)', + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)', }, custom_variables: { id: 'custom_variables', @@ -164,8 +160,6 @@ export default ngShow: "source && source.value=='custom' ", type: 'textarea', class: 'Form-textAreaLabel Form-formGroup--fullWidth', - addRequired: false, - editRequired: false, rows: 6, 'default': '---', parseTypeName: 'envParseType', @@ -187,8 +181,6 @@ export default ngShow: "source && source.value == 'ec2'", type: 'textarea', class: 'Form-textAreaLabel Form-formGroup--fullWidth', - addRequired: false, - editRequird: false, rows: 6, 'default': '---', parseTypeName: 'envParseType', @@ -209,12 +201,9 @@ export default vmware_variables: { id: 'vmware_variables', label: 'Source Variables', //"{{vars_label}}" , - ngShow: "source && source.value == 'vmware'", type: 'textarea', - addRequired: false, class: 'Form-textAreaLabel Form-formGroup--fullWidth', - editRequird: false, rows: 6, 'default': '---', parseTypeName: 'envParseType', @@ -235,12 +224,9 @@ export default openstack_variables: { id: 'openstack_variables', label: 'Source Variables', //"{{vars_label}}" , - ngShow: "source && source.value == 'openstack'", type: 'textarea', - addRequired: false, class: 'Form-textAreaLabel Form-formGroup--fullWidth', - editRequird: false, rows: 6, 'default': '---', parseTypeName: 'envParseType', @@ -263,14 +249,13 @@ export default type: 'checkbox_group', ngShow: "source && (source.value !== '' && source.value !== null)", class: 'Form-checkbox--stacked', - fields: [{ name: 'overwrite', label: 'Overwrite', type: 'checkbox', ngShow: "source.value !== '' && source.value !== null", - addRequired: false, - editRequired: false, + + awPopOver: '

        If checked, all child groups and hosts not found on the external source will be deleted from ' + 'the local inventory.

        When not checked, local child hosts and groups not found on the external source will ' + 'remain untouched by the inventory update process.

        ', @@ -278,14 +263,14 @@ export default dataContainer: 'body', dataPlacement: 'right', labelClass: 'checkbox-options', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, { name: 'overwrite_vars', label: 'Overwrite Variables', type: 'checkbox', ngShow: "source.value !== '' && source.value !== null", - addRequired: false, - editRequired: false, + + awPopOver: '

        If checked, all variables for child groups and hosts will be removed and replaced by those ' + 'found on the external source.

        When not checked, a merge will be performed, combining local variables with ' + 'those found on the external source.

        ', @@ -293,21 +278,19 @@ export default dataContainer: 'body', dataPlacement: 'right', labelClass: 'checkbox-options', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, { name: 'update_on_launch', label: 'Update on Launch', type: 'checkbox', ngShow: "source.value !== '' && source.value !== null", - addRequired: false, - editRequired: false, awPopOver: '

        Each time a job runs using this inventory, refresh the inventory from the selected source before ' + 'executing job tasks.

        ', dataTitle: 'Update on Launch', dataContainer: 'body', dataPlacement: 'right', labelClass: 'checkbox-options', - ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }] }, update_cache_timeout: { @@ -319,8 +302,6 @@ export default ngShow: "source && source.value !== '' && update_on_launch", spinner: true, "default": 0, - addRequired: false, - editRequired: false, awPopOver: '

        Time in seconds to consider an inventory sync to be current. During job runs and callbacks the task system will ' + 'evaluate the timestamp of the latest sync. If it is older than Cache Timeout, it is not considered current, ' + 'and a new inventory sync will be performed.

        ', @@ -333,16 +314,16 @@ export default buttons: { cancel: { ngClick: 'formCancel()', - ngShow: '(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, close: { ngClick: 'formCancel()', - ngShow: '!(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '!(group_obj.summary_fields.user_capabilities.edit || !canAdd)' }, save: { ngClick: 'formSave()', ngDisabled: true, - ngShow: '(group_obj.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(group_obj.summary_fields.user_capabilities.edit || !canAdd)' } }, diff --git a/awx/ui/client/src/forms/HostGroups.js b/awx/ui/client/src/forms/HostGroups.js index cfa8aea02a..2c920e79f5 100644 --- a/awx/ui/client/src/forms/HostGroups.js +++ b/awx/ui/client/src/forms/HostGroups.js @@ -26,8 +26,7 @@ export default type: 'select', multiple: true, ngOptions: 'group.name for group in inventory_groups track by group.value', - addRequired: true, - editRequired: true, + required: true, awPopOver: "

        Provide a host name, ip address, or ip address:port. Examples include:

        " + "
        myserver.domain.com
        " + "127.0.0.1
        " + diff --git a/awx/ui/client/src/forms/Hosts.js b/awx/ui/client/src/forms/Hosts.js index a1c0e69537..eae5d5c8dc 100644 --- a/awx/ui/client/src/forms/Hosts.js +++ b/awx/ui/client/src/forms/Hosts.js @@ -17,6 +17,7 @@ export default addTitle: 'Create Host', editTitle: '{{ host.name }}', name: 'host', + basePath: 'hosts', well: false, formLabelSize: 'col-lg-3', formFieldSize: 'col-lg-9', @@ -26,7 +27,6 @@ export default class: 'Form-header-field', ngClick: 'toggleHostEnabled(host)', type: 'toggle', - editRequired: false, awToolTip: "

        Indicates if a host is available and should be included in running jobs.

        For hosts that " + "are part of an external inventory, this flag cannot be changed. It will be set by the inventory sync process.

        ", dataTitle: 'Host Enabled', @@ -36,8 +36,7 @@ export default name: { label: 'Host Name', type: 'text', - addRequired: true, - editRequired: true, + required: true, awPopOver: "

        Provide a host name, ip address, or ip address:port. Examples include:

        " + "
        myserver.domain.com
        " + "127.0.0.1
        " + @@ -47,22 +46,18 @@ export default dataTitle: 'Host Name', dataPlacement: 'right', dataContainer: 'body', - ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(host.summary_fields.user_capabilities.edit || !canAdd)' }, description: { label: 'Description', - type: 'text', - addRequired: false, - editRequired: false, - ngDisabled: '!(host.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(host.summary_fields.user_capabilities.edit || !canAdd)', + type: 'text' }, variables: { label: 'Variables', type: 'textarea', - addRequired: false, - editRequird: false, rows: 6, - "class": "modal-input-xlarge Form-textArea Form-formGroup--fullWidth", + class: 'Form-formGroup--fullWidth', "default": "---", awPopOver: "

        Enter variables using either JSON or YAML syntax. Use the radio button to toggle between the two.

        " + "JSON:
        \n" + @@ -85,19 +80,16 @@ export default buttons: { cancel: { ngClick: 'formCancel()', - ngShow: '(host.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(host.summary_fields.user_capabilities.edit || !canAdd)' }, close: { ngClick: 'formCancel()', - ngShow: '!(host.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '!(host.summary_fields.user_capabilities.edit || !canAdd)' }, save: { ngClick: 'formSave()', ngDisabled: true, - ngShow: '(host.summary_fields.user_capabilities.edit || canAdd)' + ngShow: '(host.summary_fields.user_capabilities.edit || !canAdd)' } }, - - related: {} - }); diff --git a/awx/ui/client/src/forms/Inventories.js b/awx/ui/client/src/forms/Inventories.js index 0207124476..323ddd1364 100644 --- a/awx/ui/client/src/forms/Inventories.js +++ b/awx/ui/client/src/forms/Inventories.js @@ -4,91 +4,136 @@ * All Rights Reserved *************************************************/ - /** +/** * @ngdoc function * @name forms.function:Inventories * @description This form is for adding/editing an inventory -*/ + */ export default - angular.module('InventoryFormDefinition', ['ScanJobsListDefinition']) - .factory('InventoryFormObject', ['i18n', function(i18n) { +angular.module('InventoryFormDefinition', ['ScanJobsListDefinition']) + .factory('InventoryFormObject', ['i18n', function(i18n) { return { - addTitle: i18n._('New Inventory'), - editTitle: '{{ inventory_name }}', - name: 'inventory', - tabs: true, + addTitle: 'New Inventory', + editTitle: '{{ inventory_name }}', + name: 'inventory', + basePath: 'inventory', + // the top-most node of this generated state tree + stateTree: 'inventories', + tabs: true, - fields: { - inventory_name: { - realName: 'name', - label: i18n._('Name'), - type: 'text', - addRequired: true, - editRequired: true, - capitalize: false, - ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' + fields: { + inventory_name: { + realName: 'name', + label: i18n._('Name'), + type: 'text', + required: true, + capitalize: false, + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + }, + inventory_description: { + realName: 'description', + label: i18n._('Description'), + type: 'text', + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + }, + organization: { + label: i18n._('Organization'), + type: 'lookup', + basePath: 'organizations', + list: 'OrganizationList', + sourceModel: 'organization', + sourceField: 'name', + awRequiredWhen: { + reqExpression: "organizationrequired", + init: "true" }, - inventory_description: { - realName: 'description', - label: i18n._('Description'), - type: 'text', - addRequired: false, - editRequired: false, - ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + }, + variables: { + label: i18n._('Variables'), + type: 'textarea', + class: 'Form-formGroup--fullWidth', + rows: 6, + "default": "---", + awPopOver: "

        Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two.

        " + + "JSON:
        \n" + + "
        {
         \"somevar\": \"somevalue\",
         \"password\": \"magic\"
        }
        \n" + + "YAML:
        \n" + + "
        ---
        somevar: somevalue
        password: magic
        \n" + + '

        View JSON examples at www.json.org

        ' + + '

        View YAML examples at docs.ansible.com

        ', + dataTitle: 'Inventory Variables', + dataPlacement: 'right', + dataContainer: 'body', + ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' // TODO: get working + } + }, + + buttons: { + cancel: { + ngClick: 'formCancel()', + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + }, + close: { + ngClick: 'formCancel()', + ngHide: '(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + }, + save: { + ngClick: 'formSave()', + ngDisabled: true, + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + } + }, + related: { + permissions: { + awToolTip: i18n._('Please save before assigning permissions'), + dataPlacement: 'top', + basePath: 'api/v1/inventories/{{$stateParams.inventory_id}}/access_list/', + type: 'collection', + title: 'Permissions', + iterator: 'permission', + index: false, + open: false, + search: { + order_by: 'username' }, - organization: { - label: i18n._('Organization'), - type: 'lookup', - sourceModel: 'organization', - sourceField: 'name', - ngClick: 'lookUpOrganization()', - awRequiredWhen: { - reqExpression: "organizationrequired", - init: "true" + actions: { + add: { + label: i18n._('Add'), + ngClick: "$state.go('.add')", + awToolTip: 'Add a permission', + actionClass: 'btn List-buttonSubmit', + buttonContent: '+ ADD', + ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || !canAdd)' + + } + }, + fields: { + username: { + label: i18n._('User'), + linkBase: 'users', + class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' }, - ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' - }, - variables: { - label: i18n._('Variables'), - type: 'textarea', - class: 'Form-formGroup--fullWidth', - addRequired: false, - editRequird: false, - rows: 6, - "default": "---", - awPopOver: i18n._("

        Enter inventory variables using either JSON or YAML syntax. Use the radio button to toggle between the two.

        " + - "JSON:
        \n" + - "
        {
         \"somevar\": \"somevalue\",
         \"password\": \"magic\"
        }
        \n" + - "YAML:
        \n" + - "
        ---
        somevar: somevalue
        password: magic
        \n" + - '

        View JSON examples at www.json.org

        ' + - '

        View YAML examples at docs.ansible.com

        '), - dataTitle: i18n._('Inventory Variables'), - dataPlacement: 'right', - dataContainer: 'body', - ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + role: { + label: i18n._('Role'), + type: 'role', + noSort: true, + class: 'col-lg-4 col-md-4 col-sm-4 col-xs-4', + }, + team_roles: { + label: i18n._('Team Roles'), + type: 'team_roles', + noSort: true, + class: 'col-lg-5 col-md-5 col-sm-5 col-xs-4', + } } - }, + } + }, - buttons: { - cancel: { - ngClick: 'formCancel()', - ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' - }, - close: { - ngClick: 'formCancel()', - ngHide: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' - }, - save: { - ngClick: 'formSave()', - ngDisabled: true, - ngShow: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' - } - }, - - related: { + relatedSets: function(urls) { + return { permissions: { awToolTip: i18n._('Please save before assigning permissions'), dataPlacement: 'top', @@ -102,7 +147,7 @@ export default actions: { add: { ngClick: "addPermission", - label: 'Add', + label: i18n._('Add'), awToolTip: i18n._('Add a permission'), actionClass: 'btn List-buttonSubmit', buttonContent: i18n._('+ ADD'), @@ -133,28 +178,22 @@ export default } } } - }, - - relatedSets: function(urls) { - return { - permissions: { - iterator: 'permission', - url: urls.access_list - } - }; - } + }; + } };}]) - .factory('InventoryForm', ['InventoryFormObject', 'ScanJobsList', + + .factory('InventoryForm', ['InventoryFormObject', 'ScanJobsList', function(InventoryFormObject, ScanJobsList) { return function() { var itm; for (itm in InventoryFormObject.related) { if (InventoryFormObject.related[itm].include === "ScanJobsList") { - InventoryFormObject.related[itm] = ScanJobsList; - InventoryFormObject.related[itm].generateList = true; // tell form generator to call list generator and inject a list + InventoryFormObject.related[itm] = ScanJobsList; + InventoryFormObject.related[itm].generateList = true; // tell form generator to call list generator and inject a list } } return InventoryFormObject; }; - }]); + } + ]); diff --git a/awx/ui/client/src/forms/JobTemplates.js b/awx/ui/client/src/forms/JobTemplates.js index 10d5119407..bc61cf1c7d 100644 --- a/awx/ui/client/src/forms/JobTemplates.js +++ b/awx/ui/client/src/forms/JobTemplates.js @@ -19,26 +19,27 @@ export default addTitle: i18n._('New Job Template'), editTitle: '{{ name }}', - name: 'job_templates', - base: 'job_templates', + name: 'job_template', + basePath: 'job_templates', + // the top-most node of generated state tree + stateTree: 'jobTemplates', tabs: true, + // (optional) array of supporting templates to ng-include inside generated html + include: ['/static/partials/survey-maker-modal.html'], fields: { name: { label: i18n._('Name'), type: 'text', - addRequired: true, - editRequired: true, - column: 1, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)', + required: true, + column: 1 }, description: { label: i18n._('Description'), type: 'text', - addRequired: false, - editRequired: false, column: 1, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, job_type: { label: i18n._('Job Type'), @@ -46,8 +47,7 @@ export default ngOptions: 'type.label for type in job_type_options track by type.value', ngChange: 'jobTypeChange()', "default": 0, - addRequired: true, - editRequired: true, + required: true, column: 1, awPopOver: i18n._("

        When this template is submitted as a job, setting the type to run will execute the playbook, running tasks " + " on the selected hosts.

        Setting the type to check will not execute the playbook. Instead, ansible will check playbook " + @@ -61,14 +61,15 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, inventory: { label: i18n._('Inventory'), type: 'lookup', + basePath: 'inventory', + list: 'InventoryList', sourceModel: 'inventory', sourceField: 'name', - ngClick: 'lookUpInventory()', awRequiredWhen: { reqExpression: '!ask_inventory_on_launch', alwaysShowAsterisk: true @@ -84,7 +85,7 @@ export default ngShow: "!job_type.value || job_type.value !== 'scan'", text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, project: { label: i18n._('Project'), @@ -94,9 +95,10 @@ export default 'class': "{{!(job_type.value === 'scan' && project_name !== 'Default') ? 'hidden' : ''}}", }, type: 'lookup', + list: 'ProjectList', + basePath: 'projects', sourceModel: 'project', sourceField: 'name', - ngClick: 'lookUpProject()', awRequiredWhen: { reqExpression: "projectrequired", init: "true" @@ -106,7 +108,7 @@ export default dataTitle: i18n._('Project'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, playbook: { label: i18n._('Playbook'), @@ -128,9 +130,13 @@ export default credential: { label: i18n._('Machine Credential'), type: 'lookup', + list: 'CredentialList', + basePath: 'credentials', + search: { + kind: 'ssh' + }, sourceModel: 'credential', sourceField: 'name', - ngClick: 'lookUpCredential()', awRequiredWhen: { reqExpression: '!ask_credential_on_launch', alwaysShowAsterisk: true @@ -146,38 +152,42 @@ export default variable: 'ask_credential_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, cloud_credential: { label: i18n._('Cloud Credential'), type: 'lookup', + list: 'CredentialList', + basePath: 'credentials', + search: { + cloud: 'true' + }, sourceModel: 'cloud_credential', sourceField: 'name', - ngClick: 'lookUpCloudcredential()', - addRequired: false, - editRequired: false, column: 1, awPopOver: i18n._("

        Selecting an optional cloud credential in the job template will pass along the access credentials to the " + "running playbook, allowing provisioning into the cloud without manually passing parameters to the included modules.

        "), dataTitle: i18n._('Cloud Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, network_credential: { label: i18n._('Network Credential'), type: 'lookup', + list: 'CredentialList', + basePath: 'credentials', + search: { + kind: 'net' + }, sourceModel: 'network_credential', sourceField: 'name', - ngClick: 'lookUpNetworkcredential()', - addRequired: false, - editRequired: false, column: 1, awPopOver: i18n._("

        Network credentials are used by Ansible networking modules to connect to and manage networking devices.

        "), dataTitle: i18n._('Network Credential'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, forks: { label: i18n._('Forks'), @@ -187,8 +197,6 @@ export default min: 0, spinner: true, "default": '0', - addRequired: false, - editRequired: false, 'class': "input-small", column: 1, awPopOver: i18n._('

        The number of parallel or simultaneous processes to use while executing the playbook. 0 signifies ' + @@ -197,13 +205,11 @@ export default dataTitle: i18n._('Forks'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' // TODO: get working }, limit: { label: i18n._('Limit'), type: 'text', - addRequired: false, - editRequired: false, column: 1, awPopOver: i18n._("

        Provide a host pattern to further constrain the list of hosts that will be managed or affected by the playbook. " + "Multiple patterns can be separated by ; : or ,

        For more information and examples see " + @@ -215,28 +221,25 @@ export default variable: 'ask_limit_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, verbosity: { label: i18n._('Verbosity'), type: 'select', ngOptions: 'v.label for v in verbosity_options track by v.value', "default": 1, - addRequired: true, - editRequired: true, + required: true, column: 1, awPopOver: i18n._("

        Control the level of output ansible will produce as the playbook executes.

        "), dataTitle: i18n._('Verbosity'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, job_tags: { label: i18n._('Job Tags'), type: 'textarea', rows: 5, - addRequired: false, - editRequired: false, 'elementClass': 'Form-textInput', column: 2, awPopOver: i18n._("

        Provide a comma separated list of tags.

        \n" + @@ -249,14 +252,12 @@ export default variable: 'ask_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, skip_tags: { label: i18n._('Skip Tags'), type: 'textarea', rows: 5, - addRequired: false, - editRequired: false, 'elementClass': 'Form-textInput', column: 2, awPopOver: i18n._("

        Provide a comma separated list of tags.

        \n" + @@ -269,7 +270,7 @@ export default variable: 'ask_skip_tags_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, checkbox_group: { label: i18n._('Options'), @@ -278,21 +279,17 @@ export default name: 'become_enabled', label: i18n._('Enable Privilege Escalation'), type: 'checkbox', - addRequired: false, - editRequird: false, column: 2, awPopOver: i18n._("

        If enabled, run this playbook as an administrator. This is the equivalent of passing the --become option to the ansible-playbook command.

        "), dataPlacement: 'right', dataTitle: i18n._('Become Privilege Escalation'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, { name: 'allow_callbacks', label: i18n._('Allow Provisioning Callbacks'), type: 'checkbox', - addRequired: false, - editRequird: false, ngChange: "toggleCallback('host_config_key')", column: 2, awPopOver: i18n._("

        Enables creation of a provisioning callback URL. Using the URL a host can contact Tower and request a configuration update " + @@ -301,14 +298,12 @@ export default dataTitle: i18n._('Allow Provisioning Callbacks'), dataContainer: "body", labelClass: 'stack-inline', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }] }, callback_url: { label: i18n._('Provisioning Callback URL'), type: 'text', - addRequired: false, - editRequired: false, readonly: true, ngShow: "allow_callbacks && allow_callbacks !== 'false'", column: 2, @@ -317,7 +312,7 @@ export default dataPlacement: 'top', dataTitle: i18n._('Provisioning Callback URL'), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, host_config_key: { label: i18n._('Host Config Key'), @@ -331,7 +326,7 @@ export default dataPlacement: 'right', dataTitle: i18n._("Host Config Key"), dataContainer: "body", - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, labels: { label: i18n._('Labels'), @@ -339,21 +334,17 @@ export default class: 'Form-formGroup--fullWidth', ngOptions: 'label.label for label in labelOptions track by label.value', multiSelect: true, - addRequired: false, - editRequired: false, dataTitle: i18n._('Labels'), dataPlacement: 'right', awPopOver: i18n._("

        Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs in the Tower display.

        "), dataContainer: 'body', - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' }, variables: { label: i18n._('Extra Variables'), type: 'textarea', class: 'Form-textAreaLabel Form-formGroup--fullWidth', rows: 6, - addRequired: false, - editRequired: false, "default": "---", column: 2, awPopOver: i18n._("

        Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter " + @@ -369,14 +360,14 @@ export default variable: 'ask_variables_on_launch', text: i18n._('Prompt on launch') }, - ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAdd)' // TODO: get working + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || !canAdd)' // TODO: get working } }, buttons: { //for now always generates

- - + + + -
- -
- -
-
- Filter -
- - -
-
- {{ type.label }} -
-
-
-
-
- - -
-
-
-
-
- -
-
- {{tag.label}} : {{ tag.name }} - -
-
-
-
-
- diff --git a/awx/ui/client/src/search/tagSearch.service.js b/awx/ui/client/src/search/tagSearch.service.js deleted file mode 100644 index 228bcfeac7..0000000000 --- a/awx/ui/client/src/search/tagSearch.service.js +++ /dev/null @@ -1,226 +0,0 @@ -export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', function(Rest, $q, GetBasePath, Wait, ProcessErrors, $log) { - var that = this; - // parse the field config object to return - // one of the searchTypes (for the left dropdown) - this.buildType = function (field, key, id) { - var obj = {}; - // build the value (key) - var value; - if (field.searchField && field.filterBySearchField === true){ - value = field.searchField; - } - else if (field.sourceModel && field.sourceField) { - value = field.sourceModel + '__' + field.sourceField; - obj.related = true; - } else if (typeof(field.key) === String) { - value = field.key; - } else { - value = key; - } - - // build the label - var label = field.searchLabel || field.label; - - // build the search type - var type, typeOptions; - if (field.searchType === 'select') { - type = 'select'; - typeOptions = field.searchOptions || []; - } else if (field.searchType === 'boolean') { - type = 'select'; - typeOptions = field.typeOptions || [{label: "Yes", value: true}, - {label: "No", value: false}]; - } else { - type = 'text'; - } - - if (field.searchDefault) { - obj.default = true; - } - - obj.id = id; - obj.value = value; - obj.label = label; - obj.type = type; - obj.basePath = field.basePath || null; - - // return the built option - if (type === 'select') { - obj.typeOptions = typeOptions; - } - - return obj; - }; - - // given the fields that are searchable, - // return searchTypes in the format the view can use - this.getSearchTypes = function(list, basePath) { - Wait("start"); - var defer = $q.defer(); - - var options = Object - .keys(list) - .filter(function(fieldType) { - return list[fieldType].noSearch !== true; - }) - .map(function(key, id) { - return that.buildType(list[key], key, id); - }); - - var needsRequest, passThrough; - - // splits off options that need a request from - // those that don't - var partitionedOptions = _.partition(options, function(opt) { - return (opt.typeOptions && !opt.typeOptions - .length) ? true : false; - }); - - needsRequest = partitionedOptions[0]; - passThrough = partitionedOptions[1]; - - var joinOptions = function() { - var options = _.sortBy(_ - .flatten([needsRequest, passThrough]), function(opt) { - return opt.id; - }); - - // put default first - return _.flatten(_.partition(options, opt => opt.default)); - }; - - if (needsRequest.length) { - // make the options request to reutrn the typeOptions - var url = needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath; - if(url.indexOf('null') === -1 ){ - Rest.setUrl(url); - Rest.options() - .success(function (data) { - try { - var options = data.actions.GET; - needsRequest = needsRequest - .map(function (option) { - option.typeOptions = options[option - .value] - .choices - .map(function(i) { - return { - value: i[0], - label: i[1] - }; - }); - return option; - }); - } - catch(err){ - if (!basePath){ - $log.error('Cannot retrieve OPTIONS because the basePath parameter is not set on the list with the following fieldset: \n', list); - } - else { $log.error(err); } - } - Wait("stop"); - defer.resolve(joinOptions()); - }) - .error(function (data, status) { - Wait("stop"); - defer.reject("options request failed"); - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Getting type options failed'}); - }); - } - - } else { - Wait("stop"); - defer.resolve(joinOptions()); - } - - return defer.promise; - }; - - // returns the url with filter params - this.updateFilteredUrl = function(basePath, tags, pageSize, searchParams) { - // remove the chain directive from all the urls that might have - // been added previously - tags = (tags || []).map(function(val) { - if (val.url.indexOf("chain__") !== -1) { - val.url = val.url.substring(("chain__").length); - } - return val; - }); - - // separate those tags with the related: true attribute - var separateRelated = _.partition(tags, function(i) { - return i.related; - }); - - var relatedTags = separateRelated[0]; - var nonRelatedTags = separateRelated[1]; - - if (relatedTags.length > 1) { - // separate query params that need the change directive - // but have different keys - var chainGroups = _.groupBy(relatedTags, function(i) { - return i.value; - }); - - // iterate over those groups and add the "chain__" to the - // beginning of all but the first of each url - relatedTags = _.flatten(_.map(chainGroups, function(group) { - return group.map(function(val, i) { - if (i !== 0) { - val.url = "chain__" + val.url; - } - return val; - }); - })); - - // combine the related and non related tags after chainifying - tags = relatedTags.concat(nonRelatedTags); - } - - var returnedUrl = basePath; - returnedUrl += (basePath.indexOf("?") > - 1) ? "&" : "?"; - - return returnedUrl + - (tags || []).map(function (t) { - return t.url; - }).join("&") + "&page_size=" + pageSize + - ((searchParams) ? "&" + searchParams : ""); - }; - - // given the field and input filters, create the tag object - this.getTag = function(field, textVal, selectVal) { - var tag = _.clone(field); - if (tag.type === "text") { - tag.url = tag.value + "__icontains=" + encodeURIComponent(textVal); - tag.name = textVal; - } else if (selectVal.value && typeof selectVal.value === 'string' && selectVal.value.indexOf("=") > 0) { - tag.url = selectVal.value; - tag.name = selectVal.label; - } else { - tag.url = tag.value + "=" + selectVal.value; - tag.name = selectVal.label; - } - return tag; - }; - - // returns true if the newTag is already in the list of tags - this.isDuplicate = function(tags, newTag) { - return (tags - .filter(function(tag) { - return (tag.url === newTag.url); - }).length > 0); - }; - - // returns an array of tags (or empty array if there are none) - // .slice(0) is used so the currentTags variable is not directly mutated - this.getCurrentTags = function(currentTags) { - if (currentTags && currentTags.length) { - return currentTags.slice(0); - } - return []; - }; - - return this; -}]; diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index ab3bdbf8cc..f4517f47cf 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -33,7 +33,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) $rootScope.flashMessage = null; - $('#form-modal .modal-body').empty(); + //$('#form-modal .modal-body').empty(); $('#form-modal2 .modal-body').empty(); $('.tooltip').each(function() { @@ -515,7 +515,9 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) */ .factory('Wait', ['$rootScope', function($rootScope) { + return function(directive) { + /* @todo re-enable var docw, doch, spinnyw, spinnyh; if (directive === 'start' && !$rootScope.waiting) { $rootScope.waiting = true; @@ -536,6 +538,7 @@ angular.module('Utilities', ['RestServices', 'Utilities', 'sanitizeFilter']) $rootScope.waiting = false; }); } + */ }; } ]) diff --git a/awx/ui/client/src/shared/column-sort/column-sort.controller.js b/awx/ui/client/src/shared/column-sort/column-sort.controller.js new file mode 100644 index 0000000000..e5ce39ff3c --- /dev/null +++ b/awx/ui/client/src/shared/column-sort/column-sort.controller.js @@ -0,0 +1,54 @@ +export default ['$scope', '$state', 'QuerySet', 'GetBasePath', + function($scope, $state, qs, GetBasePath) { + + let queryset, path, + order_by = $state.params[`${$scope.columnIterator}_search`].order_by, + activeField = isDescending(order_by) ? order_by.substring(1, order_by.length) : order_by; + + function isDescending(str) { + if (str){ + return str.charAt(0) === '-'; + } + else{ + // default to ascending order if none is supplied + return false; + } + } + function invertOrderBy(str) { + return order_by.charAt(0) === '-' ? `${str.substring(1, str.length)}` : `-${str}`; + } + $scope.orderByIcon = function() { + // column sort is inactive + if (activeField !== $scope.columnField) { + return 'fa-sort'; + } + // column sort is active (governed by order_by) and descending + else if (isDescending(order_by)) { + return 'fa-sort-down'; + } + // column sort is active governed by order_by) and asscending + else { + return 'fa-sort-up'; + } + }; + + $scope.toggleColumnOrderBy = function() { + // toggle active sort order + if (activeField === $scope.columnField) { + order_by = invertOrderBy(order_by); + } + // set new active sort order + else { + order_by = $scope.columnField; + } + queryset = _.merge($state.params[`${$scope.columnIterator}_search`], { order_by: order_by }); + path = GetBasePath($scope.basePath) || $scope.basePath; + $state.go('.', { [$scope.iterator + '_search']: queryset }); + qs.search(path, queryset).then((res) =>{ + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + }; + + } +]; diff --git a/awx/ui/client/src/shared/column-sort/column-sort.directive.js b/awx/ui/client/src/shared/column-sort/column-sort.directive.js new file mode 100644 index 0000000000..d8e5f54bba --- /dev/null +++ b/awx/ui/client/src/shared/column-sort/column-sort.directive.js @@ -0,0 +1,19 @@ +export default ['templateUrl', function(templateUrl) { + return { + restrict: 'AE', + replace: true, + scope: { + collection: '=', + dataset: '=', + basePath: '@', + columnOrderBy: '@', + columnNoSort: '@', + columnCustomClass: '@', + columnIterator: '@', + columnField: '@', + columnLabel: '@', + }, + controller: 'ColumnSortController', + templateUrl: templateUrl('shared/column-sort/column-sort') + }; +}]; diff --git a/awx/ui/client/src/shared/column-sort/column-sort.partial.html b/awx/ui/client/src/shared/column-sort/column-sort.partial.html new file mode 100644 index 0000000000..96ea648b6c --- /dev/null +++ b/awx/ui/client/src/shared/column-sort/column-sort.partial.html @@ -0,0 +1,4 @@ + + {{columnLabel}} + + diff --git a/awx/ui/client/src/shared/column-sort/main.js b/awx/ui/client/src/shared/column-sort/main.js new file mode 100644 index 0000000000..378a5a1d02 --- /dev/null +++ b/awx/ui/client/src/shared/column-sort/main.js @@ -0,0 +1,7 @@ +import directive from './column-sort.directive'; +import controller from './column-sort.controller'; + +export default + angular.module('ColumnSortModule', []) + .directive('columnSort', directive) + .controller('ColumnSortController', controller); diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index 289e04b58d..54a5eed72b 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -5,7 +5,7 @@ *************************************************/ - /** +/** * @ngdoc function * @name shared.function:directives * @description @@ -16,114 +16,115 @@ export default angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) - // awpassmatch: Add to password_confirm field. Will test if value - // matches that of 'input[name="password"]' - .directive('awpassmatch', function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var associated = attrs.awpassmatch, - password = $('input[name="' + associated + '"]').val(); - if (viewValue === password) { - // it is valid - ctrl.$setValidity('awpassmatch', true); - return viewValue; - } - // Invalid, return undefined (no model update) - ctrl.$setValidity('awpassmatch', false); +// awpassmatch: Add to password_confirm field. Will test if value +// matches that of 'input[name="password"]' +.directive('awpassmatch', function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var associated = attrs.awpassmatch, + password = $('input[name="' + associated + '"]').val(); + if (viewValue === password) { + // it is valid + ctrl.$setValidity('awpassmatch', true); return viewValue; - }); - } - }; - }) + } + // Invalid, return undefined (no model update) + ctrl.$setValidity('awpassmatch', false); + return viewValue; + }); + } + }; +}) - // caplitalize Add to any input field where the first letter of each - // word should be capitalized. Use in place of css test-transform. - // For some reason "text-transform: capitalize" in breadcrumbs - // causes a break at each blank space. And of course, - // "autocapitalize='word'" only works in iOS. Use this as a fix. - .directive('capitalize', function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var values = viewValue.split(" "), - result = "", i; - for (i = 0; i < values.length; i++){ - result += values[i].charAt(0).toUpperCase() + values[i].substr(1) + ' '; - } - result = result.trim(); - if (result !== viewValue) { - ctrl.$setViewValue(result); - ctrl.$render(); - } - return result; - }); - } - }; - }) +// caplitalize Add to any input field where the first letter of each +// word should be capitalized. Use in place of css test-transform. +// For some reason "text-transform: capitalize" in breadcrumbs +// causes a break at each blank space. And of course, +// "autocapitalize='word'" only works in iOS. Use this as a fix. +.directive('capitalize', function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var values = viewValue.split(" "), + result = "", + i; + for (i = 0; i < values.length; i++) { + result += values[i].charAt(0).toUpperCase() + values[i].substr(1) + ' '; + } + result = result.trim(); + if (result !== viewValue) { + ctrl.$setViewValue(result); + ctrl.$render(); + } + return result; + }); + } + }; +}) - // chkPass - // - // Enables use of js/shared/pwdmeter.js to check strengh of passwords. - // See controllers/Users.js for example. - // - .directive('chkPass', [ function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - $(elm).keyup(function() { - var validity = true; - if (elm.val()) { - if ($AnsibleConfig.password_length) { - validity = (ctrl.$modelValue.length >= $AnsibleConfig.password_length); - ctrl.$setValidity('password_length', validity); - } - if ($AnsibleConfig.password_hasLowercase) { - validity = (/[a-z]/.test(ctrl.$modelValue)); - ctrl.$setValidity('hasLowercase', validity); - } - if ($AnsibleConfig.password_hasUppercase) { - validity = (/[A-Z]/.test(ctrl.$modelValue)); - ctrl.$setValidity('hasUppercase', validity); - } - if ($AnsibleConfig.password_hasNumber) { - validity = (/[0-9]/.test(ctrl.$modelValue)); - ctrl.$setValidity('hasNumber', validity); - } - if ($AnsibleConfig.password_hasSymbol) { - validity = (/[\\#@$-/:-?{-~!"^_`\[\]]/.test(ctrl.$modelValue)); - ctrl.$setValidity('hasSymbol', validity); - } - } else { - validity = true; - if ($AnsibleConfig.password_length) { - ctrl.$setValidity('password_length', validity); - } - if ($AnsibleConfig.password_hasLowercase) { - ctrl.$setValidity('hasLowercase', validity); - } - if ($AnsibleConfig.password_hasUppercase) { - ctrl.$setValidity('hasUppercase', validity); - } - if ($AnsibleConfig.password_hasNumber) { - ctrl.$setValidity('hasNumber', validity); - } - if ($AnsibleConfig.password_hasSymbol) { - ctrl.$setValidity('hasSymbol', validity); - } +// chkPass +// +// Enables use of js/shared/pwdmeter.js to check strengh of passwords. +// See controllers/Users.js for example. +// +.directive('chkPass', [function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + $(elm).keyup(function() { + var validity = true; + if (elm.val()) { + if ($AnsibleConfig.password_length) { + validity = (ctrl.$modelValue.length >= $AnsibleConfig.password_length); + ctrl.$setValidity('password_length', validity); } - if (!scope.$$phase) { - scope.$digest(); + if ($AnsibleConfig.password_hasLowercase) { + validity = (/[a-z]/.test(ctrl.$modelValue)); + ctrl.$setValidity('hasLowercase', validity); } - }); - } - }; - }]) + if ($AnsibleConfig.password_hasUppercase) { + validity = (/[A-Z]/.test(ctrl.$modelValue)); + ctrl.$setValidity('hasUppercase', validity); + } + if ($AnsibleConfig.password_hasNumber) { + validity = (/[0-9]/.test(ctrl.$modelValue)); + ctrl.$setValidity('hasNumber', validity); + } + if ($AnsibleConfig.password_hasSymbol) { + validity = (/[\\#@$-/:-?{-~!"^_`\[\]]/.test(ctrl.$modelValue)); + ctrl.$setValidity('hasSymbol', validity); + } + } else { + validity = true; + if ($AnsibleConfig.password_length) { + ctrl.$setValidity('password_length', validity); + } + if ($AnsibleConfig.password_hasLowercase) { + ctrl.$setValidity('hasLowercase', validity); + } + if ($AnsibleConfig.password_hasUppercase) { + ctrl.$setValidity('hasUppercase', validity); + } + if ($AnsibleConfig.password_hasNumber) { + ctrl.$setValidity('hasNumber', validity); + } + if ($AnsibleConfig.password_hasSymbol) { + ctrl.$setValidity('hasSymbol', validity); + } + } + if (!scope.$$phase) { + scope.$digest(); + } + }); + } + }; +}]) -.directive('surveyCheckboxes', function(){ +.directive('surveyCheckboxes', function() { return { restrict: 'E', require: 'ngModel', @@ -131,23 +132,23 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) template: '
' + ''+ + '' + + '{{option.value}}' + + '' + '
', - link: function(scope, element, attrs, ctrl){ - scope.cbModel= {}; + link: function(scope, element, attrs, ctrl) { + scope.cbModel = {}; ctrl.$setValidity('reqCheck', true); - angular.forEach(scope.ngModel.value, function(value){ + angular.forEach(scope.ngModel.value, function(value) { scope.cbModel[value] = true; }); - if(scope.ngModel.required===true && scope.ngModel.value.length===0){ + if (scope.ngModel.required === true && scope.ngModel.value.length === 0) { ctrl.$setValidity('reqCheck', false); } - ctrl.$parsers.unshift(function(viewValue){ + ctrl.$parsers.unshift(function(viewValue) { for (var c in scope.cbModel) { if (scope.cbModel[c]) { ctrl.$setValidity('checkbox', true); @@ -158,21 +159,20 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) return viewValue; }); - scope.update = function(){ + scope.update = function() { var val = []; - angular.forEach(scope.cbModel, function(v,k){ + angular.forEach(scope.cbModel, function(v, k) { if (v) { val.push(k); } }); - if (val.length>0){ + if (val.length > 0) { scope.ngModel.value = val; scope.$parent[scope.ngModel.name] = val; ctrl.$setValidity('checkbox', true); ctrl.$setValidity('reqCheck', true); - } - else if(scope.ngModel.required===true){ - ctrl.$setValidity('checkbox' , false); + } else if (scope.ngModel.required === true) { + ctrl.$setValidity('checkbox', false); } }; } @@ -180,883 +180,856 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) }) - .directive('awSurveyQuestion', function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var values = viewValue.split(" "), - result = "", i; - result += values[0].charAt(0).toUpperCase() + values[0].substr(1) + ' '; - for (i = 1; i < values.length; i++){ - result += values[i] + ' '; - } - result = result.trim(); - if (result !== viewValue) { - ctrl.$setViewValue(result); - ctrl.$render(); - } - return result; - }); - } - }; - }) +.directive('awSurveyQuestion', function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var values = viewValue.split(" "), + result = "", + i; + result += values[0].charAt(0).toUpperCase() + values[0].substr(1) + ' '; + for (i = 1; i < values.length; i++) { + result += values[i] + ' '; + } + result = result.trim(); + if (result !== viewValue) { + ctrl.$setViewValue(result); + ctrl.$render(); + } + return result; + }); + } + }; +}) - .directive('awMin', ['Empty', function (Empty) { - return { - restrict: 'A', - require: 'ngModel', - link: function (scope, elem, attr, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var min = (attr.awMin) ? scope.$eval(attr.awMin) : -Infinity; - if (!Empty(min) && !Empty(viewValue) && Number(viewValue) < min) { - ctrl.$setValidity('awMin', false); - return viewValue; - } else { - ctrl.$setValidity('awMin', true); - return viewValue; - } - }); - } - }; - }]) - - .directive('awMax', ['Empty', function (Empty) { - return { - restrict: 'A', - require: 'ngModel', - link: function (scope, elem, attr, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var max = (attr.awMax) ? scope.$eval(attr.awMax) : Infinity; - if (!Empty(max) && !Empty(viewValue) && Number(viewValue) > max) { - ctrl.$setValidity('awMax', false); - return viewValue; - } else { - ctrl.$setValidity('awMax', true); - return viewValue; - } - }); - } - }; - }]) - - .directive('smartFloat', function() { - var FLOAT_REGEXP = /^\-?\d+((\.|\,)\d+)?$/; - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift(function(viewValue) { - if (FLOAT_REGEXP.test(viewValue)) { - ctrl.$setValidity('float', true); - return parseFloat(viewValue.replace(',', '.')); - } else { - ctrl.$setValidity('float', false); - return undefined; - } - }); - } - }; - }) - - // integer Validate that input is of type integer. Taken from Angular developer - // guide, form examples. Add min and max directives, and this will check - // entered values is within the range. - // - // Use input type of 'text'. Use of 'number' casuses browser validation to - // override/interfere with this directive. - .directive('integer', function() { - return { - restrict: 'A', - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift(function(viewValue) { - ctrl.$setValidity('min', true); - ctrl.$setValidity('max', true); - if (/^\-?\d*$/.test(viewValue)) { - // it is valid - ctrl.$setValidity('integer', true); - if ( viewValue === '-' || viewValue === '-0' || viewValue === null) { - ctrl.$setValidity('integer', false); - return viewValue; - } - if (elm.attr('min') && - parseInt(viewValue,10) < parseInt(elm.attr('min'),10) ) { - ctrl.$setValidity('min', false); - return viewValue; - } - if ( elm.attr('max') && ( parseInt(viewValue,10) > parseInt(elm.attr('max'),10) ) ) { - ctrl.$setValidity('max', false); - return viewValue; - } - return viewValue; - } - // Invalid, return undefined (no model update) - ctrl.$setValidity('integer', false); +.directive('awMin', ['Empty', function(Empty) { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, elem, attr, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var min = (attr.awMin) ? scope.$eval(attr.awMin) : -Infinity; + if (!Empty(min) && !Empty(viewValue) && Number(viewValue) < min) { + ctrl.$setValidity('awMin', false); return viewValue; - }); - } - }; - }) + } else { + ctrl.$setValidity('awMin', true); + return viewValue; + } + }); + } + }; +}]) - //the awSurveyVariableName directive checks if the field contains any spaces. - // this could be elaborated in the future for other things we want to check this field against - .directive('awSurveyVariableName', function() { - var FLOAT_REGEXP = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/; - return { - restrict: 'A', - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$setValidity('required', true); // we only want the error message for incorrect characters to be displayed - ctrl.$parsers.unshift(function(viewValue) { - if(viewValue.length !== 0){ - if (FLOAT_REGEXP.test(viewValue) && viewValue.indexOf(' ') === -1) { //check for a spaces - ctrl.$setValidity('variable', true); - return viewValue; - } - else{ - ctrl.$setValidity('variable', false); // spaces found, therefore throw error. - return viewValue; - } - } - else{ - ctrl.$setValidity('variable', true); // spaces found, therefore throw error. +.directive('awMax', ['Empty', function(Empty) { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, elem, attr, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var max = (attr.awMax) ? scope.$eval(attr.awMax) : Infinity; + if (!Empty(max) && !Empty(viewValue) && Number(viewValue) > max) { + ctrl.$setValidity('awMax', false); + return viewValue; + } else { + ctrl.$setValidity('awMax', true); + return viewValue; + } + }); + } + }; +}]) + +.directive('smartFloat', function() { + var FLOAT_REGEXP = /^\-?\d+((\.|\,)\d+)?$/; + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + if (FLOAT_REGEXP.test(viewValue)) { + ctrl.$setValidity('float', true); + return parseFloat(viewValue.replace(',', '.')); + } else { + ctrl.$setValidity('float', false); + return undefined; + } + }); + } + }; +}) + +// integer Validate that input is of type integer. Taken from Angular developer +// guide, form examples. Add min and max directives, and this will check +// entered values is within the range. +// +// Use input type of 'text'. Use of 'number' casuses browser validation to +// override/interfere with this directive. +.directive('integer', function() { + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + ctrl.$setValidity('min', true); + ctrl.$setValidity('max', true); + if (/^\-?\d*$/.test(viewValue)) { + // it is valid + ctrl.$setValidity('integer', true); + if (viewValue === '-' || viewValue === '-0' || viewValue === null) { + ctrl.$setValidity('integer', false); return viewValue; - } - }); + } + if (elm.attr('min') && + parseInt(viewValue, 10) < parseInt(elm.attr('min'), 10)) { + ctrl.$setValidity('min', false); + return viewValue; + } + if (elm.attr('max') && (parseInt(viewValue, 10) > parseInt(elm.attr('max'), 10))) { + ctrl.$setValidity('max', false); + return viewValue; + } + return viewValue; + } + // Invalid, return undefined (no model update) + ctrl.$setValidity('integer', false); + return viewValue; + }); + } + }; +}) + +//the awSurveyVariableName directive checks if the field contains any spaces. +// this could be elaborated in the future for other things we want to check this field against +.directive('awSurveyVariableName', function() { + var FLOAT_REGEXP = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/; + return { + restrict: 'A', + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$setValidity('required', true); // we only want the error message for incorrect characters to be displayed + ctrl.$parsers.unshift(function(viewValue) { + if (viewValue.length !== 0) { + if (FLOAT_REGEXP.test(viewValue) && viewValue.indexOf(' ') === -1) { //check for a spaces + ctrl.$setValidity('variable', true); + return viewValue; + } else { + ctrl.$setValidity('variable', false); // spaces found, therefore throw error. + return viewValue; + } + } else { + ctrl.$setValidity('variable', true); // spaces found, therefore throw error. + return viewValue; + } + }); + } + }; +}) + +// +// awRequiredWhen: { reqExpression: "", init: "true|false" } +// +// Make a field required conditionally using an expression. If the expression evaluates to true, the +// field will be required. Otherwise, the required attribute will be removed. +// +.directive('awRequiredWhen', function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + + function updateRequired() { + var isRequired = scope.$eval(attrs.awRequiredWhen); + + var viewValue = elm.val(), + label, validity = true; + label = $(elm).closest('.form-group').find('label').first(); + + if (isRequired && (elm.attr('required') === null || elm.attr('required') === undefined)) { + $(elm).attr('required', 'required'); + $(label).removeClass('prepend-asterisk').addClass('prepend-asterisk'); + } else if (!isRequired) { + elm.removeAttr('required'); + if (!attrs.awrequiredAlwaysShowAsterisk) { + $(label).removeClass('prepend-asterisk'); + } + } + if (isRequired && (viewValue === undefined || viewValue === null || viewValue === '')) { + validity = false; + } + ctrl.$setValidity('required', validity); } - }; - }) - // - // awRequiredWhen: { reqExpression: "", init: "true|false" } - // - // Make a field required conditionally using an expression. If the expression evaluates to true, the - // field will be required. Otherwise, the required attribute will be removed. - // - .directive('awRequiredWhen', function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { + scope.$watchGroup([attrs.awRequiredWhen, $(elm).attr('name')], function() { + // watch for the aw-required-when expression to change value + updateRequired(); + }); - function updateRequired () { - var isRequired = scope.$eval(attrs.awRequiredWhen); + if (attrs.awrequiredInit !== undefined && attrs.awrequiredInit !== null) { + // We already set a watcher on the attribute above so no need to call updateRequired() in here + scope[attrs.awRequiredWhen] = attrs.awrequiredInit; + } - var viewValue = elm.val(), label, validity = true; - label = $(elm).closest('.form-group').find('label').first(); + } + }; +}) - if ( isRequired && (elm.attr('required') === null || elm.attr('required') === undefined) ) { - $(elm).attr('required','required'); - $(label).removeClass('prepend-asterisk').addClass('prepend-asterisk'); - } - else if (!isRequired) { - elm.removeAttr('required'); - if(!attrs.awrequiredAlwaysShowAsterisk) { - $(label).removeClass('prepend-asterisk'); - } - } - if (isRequired && (viewValue === undefined || viewValue === null || viewValue === '')) { +// awPlaceholder: Dynamic placeholder set to a scope variable you want watched. +// Value will be place in field placeholder attribute. +.directive('awPlaceholder', [function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs) { + $(elm).attr('placeholder', scope[attrs.awPlaceholder]); + scope.$watch(attrs.awPlaceholder, function(newVal) { + $(elm).attr('placeholder', newVal); + }); + } + }; +}]) + +// lookup Validate lookup value against API +.directive('awlookup', ['Rest', 'GetBasePath', '$q', function(Rest, GetBasePath, $q) { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + + let query, + basePath, + defer = $q.defer(); + + // query the API to see if field value corresponds to a valid resource + // .ng-pending will be applied to the directive element while the request is outstanding + ctrl.$asyncValidators.validResource = function(modelValue, viewValue) { + if (viewValue) { + basePath = GetBasePath(elm.attr('data-basePath')) || elm.attr('data-basePath'); + query = elm.attr('data-query'); + query = query.replace(/\:value/, encodeURI(viewValue)); + Rest.setUrl(`${basePath}${query}`); + // https://github.com/ansible/ansible-tower/issues/3549 + // capturing both success/failure conditions in .then() promise + // when #3549 is resolved, this will need to be partitioned into success/error or then/catch blocks + return Rest.get() + .then((res) => { + if (res.data.results.length > 0) { + scope[elm.attr('data-source')] = res.data.results[0].id; + ctrl.$setValidity('awlookup', true); + defer.resolve(true); + } else { + scope[elm.attr('data-source')] = null; + ctrl.$setValidity('awlookup', false); + defer.resolve(false); + } + }); + } + return defer.promise; + }; + } + }; +}]) + +// +// awValidUrl +// +.directive('awValidUrl', [function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + ctrl.$parsers.unshift(function(viewValue) { + var validity = true, + rgx, rgx2; + if (viewValue !== '') { + ctrl.$setValidity('required', true); + rgx = /^(https|http|ssh)\:\/\//; + rgx2 = /\@/g; + if (!rgx.test(viewValue) || rgx2.test(viewValue)) { validity = false; } - ctrl.$setValidity('required', validity); } + ctrl.$setValidity('awvalidurl', validity); - scope.$watchGroup([attrs.awRequiredWhen, $(elm).attr('name')], function() { - // watch for the aw-required-when expression to change value - updateRequired(); - }); - - if (attrs.awrequiredInit !== undefined && attrs.awrequiredInit !== null) { - // We already set a watcher on the attribute above so no need to call updateRequired() in here - scope[attrs.awRequiredWhen] = attrs.awrequiredInit; - } + return viewValue; + }); + } + }; +}]) +/* + * Enable TB tooltips. To add a tooltip to an element, include the following directive in + * the element's attributes: + * + * aw-tool-tip="<< tooltip text here >>" + * + * Include the standard TB data-XXX attributes to controll a tooltip's appearance. We will + * default placement to the left and delay to the config setting. + */ +.directive('awToolTip', [function() { + return { + link: function(scope, element, attrs) { + var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : { show: 500, hide: 100 }, + placement, + stateChangeWatcher; + if (attrs.awTipPlacement) { + placement = attrs.awTipPlacement; + } else { + placement = (attrs.placement !== undefined && attrs.placement !== null) ? attrs.placement : 'left'; } - }; - }) - // awPlaceholder: Dynamic placeholder set to a scope variable you want watched. - // Value will be place in field placeholder attribute. - .directive('awPlaceholder', [ function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs) { - $(elm).attr('placeholder', scope[attrs.awPlaceholder]); - scope.$watch(attrs.awPlaceholder, function(newVal) { - $(elm).attr('placeholder',newVal); - }); + var template, custom_class; + if (attrs.tooltipInnerClass || attrs.tooltipinnerclass) { + custom_class = attrs.tooltipInnerClass || attrs.tooltipinnerclass; + template = ''; + } else { + template = ''; } - }; - }]) - // lookup Validate lookup value against API - // - .directive('awlookup', ['Rest', '$timeout', function(Rest, $timeout) { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - - var restTimeout; - - ctrl.$parsers.unshift( function(viewValue) { - if (viewValue !== '' && viewValue !== null) { - var url = elm.attr('data-url'); - url = url.replace(/\:value/, encodeURI(viewValue)); - scope[elm.attr('data-source')] = null; - if(restTimeout) { - $timeout.cancel(restTimeout); - } - restTimeout = $timeout( function(){ - Rest.setUrl(url); - Rest.get().then( function(data) { - var results = data.data.results; - if (results.length > 0) { - scope[elm.attr('data-source')] = results[0].id; - - // For user lookups the API endpoint doesn't - // have a `name` property, so this is `undefined` - // which causes the input to clear after typing - // a valid value O_o - // - // Only assign if there is a value, so that we avoid - // this situation. - // - // TODO: Evaluate if assigning name on the scope is - // even necessary at all. - // - if (!_.isEmpty(results[0].name)) { - scope[elm.attr('name')] = results[0].name; - } - - ctrl.$setValidity('required', true); - ctrl.$setValidity('awlookup', true); - return viewValue; - } - ctrl.$setValidity('required', true); - ctrl.$setValidity('awlookup', false); - return undefined; - }); - }, 750); - } - else { - if(restTimeout) { - $timeout.cancel(restTimeout); - } - ctrl.$setValidity('awlookup', true); - scope[elm.attr('data-source')] = null; - } - return viewValue; - }); - } - }; - }]) - - // - // awValidUrl - // - .directive('awValidUrl', [ function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - ctrl.$parsers.unshift( function(viewValue) { - var validity = true, rgx, rgx2; - if (viewValue !== '') { - ctrl.$setValidity('required', true); - rgx = /^(https|http|ssh)\:\/\//; - rgx2 = /\@/g; - if (!rgx.test(viewValue) || rgx2.test(viewValue)) { - validity = false; - } - } - ctrl.$setValidity('awvalidurl', validity); - - return viewValue; - }); - } - }; - }]) - - /* - * Enable TB tooltips. To add a tooltip to an element, include the following directive in - * the element's attributes: - * - * aw-tool-tip="<< tooltip text here >>" - * - * Include the standard TB data-XXX attributes to controll a tooltip's appearance. We will - * default placement to the left and delay to the config setting. - */ - .directive('awToolTip', [ function() { - return { - link: function(scope, element, attrs) { - var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : {show: 500, hide: 100}, - placement, - stateChangeWatcher; - if (attrs.awTipPlacement) { - placement = attrs.awTipPlacement; + // This block helps clean up tooltips that may get orphaned by a click event + $(element).on('mouseenter', function() { + if (stateChangeWatcher) { + // Un-bind - we don't want a bunch of listeners firing + stateChangeWatcher(); } - else { - placement = (attrs.placement !== undefined && attrs.placement !== null) ? attrs.placement : 'left'; - } - - var template, custom_class; - if (attrs.tooltipInnerClass || attrs.tooltipinnerclass) { - custom_class = attrs.tooltipInnerClass || attrs.tooltipinnerclass; - template = ''; - } else { - template = ''; - } - - // This block helps clean up tooltips that may get orphaned by a click event - $(element).on('mouseenter', function() { - if(stateChangeWatcher) { - // Un-bind - we don't want a bunch of listeners firing - stateChangeWatcher(); - } - stateChangeWatcher = scope.$on('$stateChangeStart', function() { - // Go ahead and force the tooltip setTimeout to expire (if it hasn't already fired) - $(element).tooltip('hide'); - // Clean up any existing tooltips including this one - $('.tooltip').each(function() { - $(this).remove(); - }); - }); - }); - - $(element).on('hidden.bs.tooltip', function( ) { - // TB3RC1 is leaving behind tooltip
elements. This will remove them - // after a tooltip fades away. If not, they lay overtop of other elements and - // honk up the page. + stateChangeWatcher = scope.$on('$stateChangeStart', function() { + // Go ahead and force the tooltip setTimeout to expire (if it hasn't already fired) + $(element).tooltip('hide'); + // Clean up any existing tooltips including this one $('.tooltip').each(function() { $(this).remove(); }); }); - - $(element).tooltip({ - placement: placement, - delay: delay, - html: true, - title: attrs.awToolTip, - container: 'body', - trigger: 'hover', - template: template - }); - - if (attrs.tipWatch) { - // Add dataTipWatch: 'variable_name' - scope.$watch(attrs.tipWatch, function(newVal, oldVal) { - if (newVal !== oldVal) { - // Where did fixTitle come from?: - // http://stackoverflow.com/questions/9501921/change-twitter-bootstrap-tooltip-content-on-click - $(element).tooltip('hide').attr('data-original-title', newVal).tooltip('fixTitle'); - } - }); - } - } - }; - }]) - - /* - * Enable TB pop-overs. To add a pop-over to an element, include the following directive in - * the element's attributes: - * - * aw-pop-over="<< pop-over html here >>" - * - * Include the standard TB data-XXX attributes to controll the pop-over's appearance. We will - * default placement to the left, delay to 0 seconds, content type to HTML, and title to 'Help'. - */ - .directive('awPopOver', ['$compile', function($compile) { - return function(scope, element, attrs) { - var placement = (attrs.placement !== undefined && attrs.placement !== null) ? attrs.placement : 'left', - title = (attrs.overTitle) ? attrs.overTitle : (attrs.popoverTitle) ? attrs.popoverTitle : 'Help', - container = (attrs.container !== undefined) ? attrs.container : false, - trigger = (attrs.trigger !== undefined) ? attrs.trigger : 'manual', - template = '', - id_to_close = ""; - - if (element[0].id) { - template = ''; - } - - scope.triggerPopover = function(e){ - showPopover(e); - }; - - if (attrs.awPopOverWatch) { - $(element).popover({ - placement: placement, - delay: 0, - title: title, - content: function() { - return scope[attrs.awPopOverWatch]; - }, - trigger: trigger, - html: true, - container: container, - template: template - }); - } else { - $(element).popover({ - placement: placement, - delay: 0, - title: title, - content: attrs.awPopOver, - trigger: trigger, - html: true, - container: container, - template: template - }); - } - $(element).attr('tabindex',-1); - - $(element).one('click', showPopover); - - function bindPopoverDismiss() { - $('body').one('click.popover' + id_to_close, function(e) { - if ($(e.target).parents(id_to_close).length === 0) { - // case: you clicked to open the popover and then you - // clicked outside of it...hide it. - $(element).popover('hide'); - } else { - // case: you clicked to open the popover and then you - // clicked inside the popover - bindPopoverDismiss(); - } - }); - } - - $(element).on('shown.bs.popover', function() { - bindPopoverDismiss(); - $(document).on('keydown.popover', dismissOnEsc); }); - $(element).on('hidden.bs.popover', function() { - $(element).off('click', dismissPopover); - $(element).off('click', showPopover); - $('body').off('click.popover.' + id_to_close); - $(element).one('click', showPopover); - $(document).off('keydown.popover', dismissOnEsc); - }); - - function showPopover(e) { - e.stopPropagation(); - - var self = $(element); - - // remove tool-tip - try { - element.tooltip('hide'); - } - catch(ex) { - // ignore - } - - // this is called on the help-link (over and over again) - $('.help-link, .help-link-white').each( function() { - if (self.attr('id') !== $(this).attr('id')) { - try { - // not sure what this does different than the method above - $(this).popover('hide'); - } - catch(e) { - // ignore - } - } - }); - - $('.popover').each(function() { - // remove lingering popover
. Seems to be a bug in TB3 RC1 + $(element).on('hidden.bs.tooltip', function() { + // TB3RC1 is leaving behind tooltip
elements. This will remove them + // after a tooltip fades away. If not, they lay overtop of other elements and + // honk up the page. + $('.tooltip').each(function() { $(this).remove(); }); - $('.tooltip').each( function() { - // close any lingering tool tips - $(this).hide(); - }); + }); - // set id_to_close of the actual open element - id_to_close = "#" + $(element).attr('id') + "_popover_container"; + $(element).tooltip({ + placement: placement, + delay: delay, + html: true, + title: attrs.awToolTip, + container: 'body', + trigger: 'hover', + template: template + }); - // $(element).one('click', dismissPopover); - - $(element).popover('toggle'); - - $('.popover').each(function() { - $compile($(this))(scope); //make nested directives work! + if (attrs.tipWatch) { + // Add dataTipWatch: 'variable_name' + scope.$watch(attrs.tipWatch, function(newVal, oldVal) { + if (newVal !== oldVal) { + // Where did fixTitle come from?: + // http://stackoverflow.com/questions/9501921/change-twitter-bootstrap-tooltip-content-on-click + $(element).tooltip('hide').attr('data-original-title', newVal).tooltip('fixTitle'); + } }); } + } + }; +}]) - function dismissPopover(e) { - e.stopPropagation(); - $(element).popover('hide'); - } +/* + * Enable TB pop-overs. To add a pop-over to an element, include the following directive in + * the element's attributes: + * + * aw-pop-over="<< pop-over html here >>" + * + * Include the standard TB data-XXX attributes to controll the pop-over's appearance. We will + * default placement to the left, delay to 0 seconds, content type to HTML, and title to 'Help'. + */ +.directive('awPopOver', ['$compile', function($compile) { + return function(scope, element, attrs) { + var placement = (attrs.placement !== undefined && attrs.placement !== null) ? attrs.placement : 'left', + title = (attrs.overTitle) ? attrs.overTitle : (attrs.popoverTitle) ? attrs.popoverTitle : 'Help', + container = (attrs.container !== undefined) ? attrs.container : false, + trigger = (attrs.trigger !== undefined) ? attrs.trigger : 'manual', + template = '', + id_to_close = ""; - function dismissOnEsc(e) { - if (e.keyCode === 27) { + if (element[0].id) { + template = ''; + } + + scope.triggerPopover = function(e) { + showPopover(e); + }; + + if (attrs.awPopOverWatch) { + $(element).popover({ + placement: placement, + delay: 0, + title: title, + content: function() { + return scope[attrs.awPopOverWatch]; + }, + trigger: trigger, + html: true, + container: container, + template: template + }); + } else { + $(element).popover({ + placement: placement, + delay: 0, + title: title, + content: attrs.awPopOver, + trigger: trigger, + html: true, + container: container, + template: template + }); + } + $(element).attr('tabindex', -1); + + $(element).one('click', showPopover); + + function bindPopoverDismiss() { + $('body').one('click.popover' + id_to_close, function(e) { + if ($(e.target).parents(id_to_close).length === 0) { + // case: you clicked to open the popover and then you + // clicked outside of it...hide it. $(element).popover('hide'); - $('.popover').each(function() { - // remove lingering popover
. Seems to be a bug in TB3 RC1 - // $(this).remove(); - }); + } else { + // case: you clicked to open the popover and then you + // clicked inside the popover + bindPopoverDismiss(); } + }); + } + + $(element).on('shown.bs.popover', function() { + bindPopoverDismiss(); + $(document).on('keydown.popover', dismissOnEsc); + }); + + $(element).on('hidden.bs.popover', function() { + $(element).off('click', dismissPopover); + $(element).off('click', showPopover); + $('body').off('click.popover.' + id_to_close); + $(element).one('click', showPopover); + $(document).off('keydown.popover', dismissOnEsc); + }); + + function showPopover(e) { + e.stopPropagation(); + + var self = $(element); + + // remove tool-tip + try { + element.tooltip('hide'); + } catch (ex) { + // ignore } - }; - }]) - - // - // Enable jqueryui slider widget on a numeric input field - // - // - // - .directive('awSlider', [ function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - var name = elm.attr('name'); - $('#' + name + '-slider').slider({ - value: 0, - step: 1, - min: elm.attr('min'), - max: elm.attr('max'), - disabled: (elm.attr('readonly')) ? true : false, - slide: function(e,u) { - ctrl.$setViewValue(u.value); - ctrl.$setValidity('required',true); - ctrl.$setValidity('min', true); - ctrl.$setValidity('max', true); - ctrl.$dirty = true; - ctrl.$render(); - if (!scope.$$phase) { - scope.$digest(); - } + // this is called on the help-link (over and over again) + $('.help-link, .help-link-white').each(function() { + if (self.attr('id') !== $(this).attr('id')) { + try { + // not sure what this does different than the method above + $(this).popover('hide'); + } catch (e) { + // ignore } - }); - - $('#' + name + '-number').change( function() { - $('#' + name + '-slider').slider('value', parseInt($(this).val(),10)); - }); - - } - }; - }]) - - // - // Enable jqueryui spinner widget on a numeric input field - // - // - // - .directive('awSpinner', [ function() { - return { - require: 'ngModel', - link: function(scope, elm, attrs, ctrl) { - var disabled, opts; - disabled = elm.attr('data-disabled'); - opts = { - value: 0, - step: 1, - min: elm.attr('min'), - max: elm.attr('max'), - numberFormat: "d", - disabled: (elm.attr('readonly')) ? true : false, - icons: { - down: "Form-numberInputButton fa fa-angle-down", - up: "Form-numberInputButton fa fa-angle-up" - }, - spin: function(e, u) { - ctrl.$setViewValue(u.value); - ctrl.$setValidity('required',true); - ctrl.$setValidity('min', true); - ctrl.$setValidity('max', true); - ctrl.$dirty = true; - ctrl.$render(); - if (scope.job_templates_form) { - // need a way to find the parent form and mark it dirty - scope.job_templates_form.$dirty = true; - } - if (!scope.$$phase) { - scope.$digest(); - } - } - }; - if (disabled) { - opts.disabled = true; } - $(elm).spinner(opts); - $('.ui-icon').text(''); - $(".ui-icon").removeClass('ui-icon ui-icon-triangle-1-n ui-icon-triangle-1-s'); - $(elm).on("click", function () { - $(elm).select(); + }); + + $('.popover').each(function() { + // remove lingering popover
. Seems to be a bug in TB3 RC1 + $(this).remove(); + }); + $('.tooltip').each(function() { + // close any lingering tool tips + $(this).hide(); + }); + + // set id_to_close of the actual open element + id_to_close = "#" + $(element).attr('id') + "_popover_container"; + + // $(element).one('click', dismissPopover); + + $(element).popover('toggle'); + + $('.popover').each(function() { + $compile($(this))(scope); //make nested directives work! + }); + } + + function dismissPopover(e) { + e.stopPropagation(); + $(element).popover('hide'); + } + + function dismissOnEsc(e) { + if (e.keyCode === 27) { + $(element).popover('hide'); + $('.popover').each(function() { + // remove lingering popover
. Seems to be a bug in TB3 RC1 + // $(this).remove(); }); } - }; - }]) + } - // - // awRefresh - // - // Creates a timer to call scope.refresh(iterator) ever N seconds, where - // N is a setting in config.js - // - .directive('awRefresh', [ '$rootScope', function($rootScope) { - return { - link: function(scope) { - function msg() { - var num = '' + scope.refreshCnt; - while (num.length < 2) { - num = '0' + num; - } - return 'Refresh in ' + num + ' sec.'; - } - scope.refreshCnt = $AnsibleConfig.refresh_rate; - scope.refreshMsg = msg(); - if ($rootScope.timer) { - clearInterval($rootScope.timer); - } - $rootScope.timer = setInterval( function() { - scope.refreshCnt--; - if (scope.refreshCnt <= 0) { - scope.refresh(); - scope.refreshCnt = $AnsibleConfig.refresh_rate; - } - scope.refreshMsg = msg(); + }; +}]) + +// +// Enable jqueryui slider widget on a numeric input field +// +// +// +.directive('awSlider', [function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + var name = elm.attr('name'); + $('#' + name + '-slider').slider({ + value: 0, + step: 1, + min: elm.attr('min'), + max: elm.attr('max'), + disabled: (elm.attr('readonly')) ? true : false, + slide: function(e, u) { + ctrl.$setViewValue(u.value); + ctrl.$setValidity('required', true); + ctrl.$setValidity('min', true); + ctrl.$setValidity('max', true); + ctrl.$dirty = true; + ctrl.$render(); if (!scope.$$phase) { scope.$digest(); } - }, 1000); + } + }); + + $('#' + name + '-number').change(function() { + $('#' + name + '-slider').slider('value', parseInt($(this).val(), 10)); + }); + + } + }; +}]) + +// +// Enable jqueryui spinner widget on a numeric input field +// +// +// +.directive('awSpinner', [function() { + return { + require: 'ngModel', + link: function(scope, elm, attrs, ctrl) { + var disabled, opts; + disabled = elm.attr('data-disabled'); + opts = { + value: 0, + step: 1, + min: elm.attr('min'), + max: elm.attr('max'), + numberFormat: "d", + disabled: (elm.attr('readonly')) ? true : false, + icons: { + down: "Form-numberInputButton fa fa-angle-down", + up: "Form-numberInputButton fa fa-angle-up" + }, + spin: function(e, u) { + ctrl.$setViewValue(u.value); + ctrl.$setValidity('required', true); + ctrl.$setValidity('min', true); + ctrl.$setValidity('max', true); + ctrl.$dirty = true; + ctrl.$render(); + if (scope.job_templates_form) { + // need a way to find the parent form and mark it dirty + scope.job_templates_form.$dirty = true; + } + if (!scope.$$phase) { + scope.$digest(); + } + } + }; + if (disabled) { + opts.disabled = true; } - }; - }]) + $(elm).spinner(opts); + $('.ui-icon').text(''); + $(".ui-icon").removeClass('ui-icon ui-icon-triangle-1-n ui-icon-triangle-1-s'); + $(elm).on("click", function() { + $(elm).select(); + }); + } + }; +}]) - /* - * Make an element draggable. Used on inventory groups tree. - * - * awDraggable: boolean || {{ expression }} - * - */ - .directive('awDraggable', [ function() { - return function(scope, element, attrs) { - - if (attrs.awDraggable === "true") { - var containment = attrs.containment; //provide dataContainment:"#id" - $(element).draggable({ - containment: containment, - scroll: true, - revert: "invalid", - helper: "clone", - start: function(e, ui) { - ui.helper.addClass('draggable-clone'); - }, - zIndex: 100, - cursorAt: { left: -1 } - }); +// +// awRefresh +// +// Creates a timer to call scope.refresh(iterator) ever N seconds, where +// N is a setting in config.js +// +.directive('awRefresh', ['$rootScope', function($rootScope) { + return { + link: function(scope) { + function msg() { + var num = '' + scope.refreshCnt; + while (num.length < 2) { + num = '0' + num; + } + return 'Refresh in ' + num + ' sec.'; } - }; - }]) + scope.refreshCnt = $AnsibleConfig.refresh_rate; + scope.refreshMsg = msg(); + if ($rootScope.timer) { + clearInterval($rootScope.timer); + } + $rootScope.timer = setInterval(function() { + scope.refreshCnt--; + if (scope.refreshCnt <= 0) { + scope.refresh(); + scope.refreshCnt = $AnsibleConfig.refresh_rate; + } + scope.refreshMsg = msg(); + if (!scope.$$phase) { + scope.$digest(); + } + }, 1000); + } + }; +}]) - /* - * Make an element droppable- it can receive draggable elements - * - * awDroppable: boolean || {{ expression }} - * - */ - .directive('awDroppable', ['Find', function(Find) { - return function(scope, element, attrs) { - var node; - if (attrs.awDroppable === "true") { - $(element).droppable({ - // the following is inventory specific accept checking and - // drop processing. - accept: function(draggable) { - if (draggable.attr('data-type') === 'group') { - // Dropped a group - if ($(this).attr('data-group-id') === draggable.attr('data-group-id')) { - // No dropping a node onto itself (or a copy) - return false; - } - // No dropping a node into a group that already has the node - node = Find({ list: scope.groups, key: 'id', val: parseInt($(this).attr('data-tree-id'),10) }); - if (node) { - var group = parseInt(draggable.attr('data-group-id'),10), - found = false, i; - // For whatever reason indexOf() would not work... - for (i=0; i < node.children.length; i++) { - if (node.children[i] === group) { - found = true; - break; - } - } - return (found) ? false : true; - } +/* + * Make an element draggable. Used on inventory groups tree. + * + * awDraggable: boolean || {{ expression }} + * + */ +.directive('awDraggable', [function() { + return function(scope, element, attrs) { + + if (attrs.awDraggable === "true") { + var containment = attrs.containment; //provide dataContainment:"#id" + $(element).draggable({ + containment: containment, + scroll: true, + revert: "invalid", + helper: "clone", + start: function(e, ui) { + ui.helper.addClass('draggable-clone'); + }, + zIndex: 100, + cursorAt: { left: -1 } + }); + } + }; +}]) + +/* + * Make an element droppable- it can receive draggable elements + * + * awDroppable: boolean || {{ expression }} + * + */ +.directive('awDroppable', ['Find', function(Find) { + return function(scope, element, attrs) { + var node; + if (attrs.awDroppable === "true") { + $(element).droppable({ + // the following is inventory specific accept checking and + // drop processing. + accept: function(draggable) { + if (draggable.attr('data-type') === 'group') { + // Dropped a group + if ($(this).attr('data-group-id') === draggable.attr('data-group-id')) { + // No dropping a node onto itself (or a copy) return false; } - if (draggable.attr('data-type') === 'host') { - // Dropped a host - node = Find({ list: scope.groups, key: 'id', val: parseInt($(this).attr('data-tree-id'),10) }); - return (node.id > 1) ? true : false; + // No dropping a node into a group that already has the node + node = Find({ list: scope.groups, key: 'id', val: parseInt($(this).attr('data-tree-id'), 10) }); + if (node) { + var group = parseInt(draggable.attr('data-group-id'), 10), + found = false, + i; + // For whatever reason indexOf() would not work... + for (i = 0; i < node.children.length; i++) { + if (node.children[i] === group) { + found = true; + break; + } + } + return (found) ? false : true; } return false; - }, - over: function() { - $(this).addClass('droppable-hover'); - }, - out: function() { - $(this).removeClass('droppable-hover'); - }, - drop: function(e, ui) { - // Drag-n-drop succeeded. Trigger a response from the inventory.edit controller - $(this).removeClass('droppable-hover'); - if (ui.draggable.attr('data-type') === 'group') { - scope.$emit('CopyMoveGroup', parseInt(ui.draggable.attr('data-tree-id'),10), - parseInt($(this).attr('data-tree-id'),10)); - } - else if (ui.draggable.attr('data-type') === 'host') { - scope.$emit('CopyMoveHost', parseInt($(this).attr('data-tree-id'),10), - parseInt(ui.draggable.attr('data-host-id'),10)); - } - }, - tolerance: 'pointer' + } + if (draggable.attr('data-type') === 'host') { + // Dropped a host + node = Find({ list: scope.groups, key: 'id', val: parseInt($(this).attr('data-tree-id'), 10) }); + return (node.id > 1) ? true : false; + } + return false; + }, + over: function() { + $(this).addClass('droppable-hover'); + }, + out: function() { + $(this).removeClass('droppable-hover'); + }, + drop: function(e, ui) { + // Drag-n-drop succeeded. Trigger a response from the inventory.edit controller + $(this).removeClass('droppable-hover'); + if (ui.draggable.attr('data-type') === 'group') { + scope.$emit('CopyMoveGroup', parseInt(ui.draggable.attr('data-tree-id'), 10), + parseInt($(this).attr('data-tree-id'), 10)); + } else if (ui.draggable.attr('data-type') === 'host') { + scope.$emit('CopyMoveHost', parseInt($(this).attr('data-tree-id'), 10), + parseInt(ui.draggable.attr('data-host-id'), 10)); + } + }, + tolerance: 'pointer' + }); + } + }; +}]) + + +.directive('awAccordion', ['Empty', '$location', 'Store', function(Empty, $location, Store) { + return function(scope, element, attrs) { + var active, + list = Store('accordions'), + id, base; + + if (!Empty(attrs.openFirst)) { + active = 0; + } else { + // Look in storage for last active panel + if (list) { + id = $(element).attr('id'); + base = ($location.path().replace(/^\//, '').split('/')[0]); + list.every(function(elem) { + if (elem.base === base && elem.id === id) { + active = elem.active; + return false; + } + return true; }); } - }; - }]) + active = (Empty(active)) ? 0 : active; + } - - .directive('awAccordion', ['Empty', '$location', 'Store', function(Empty, $location, Store) { - return function(scope, element, attrs) { - var active, - list = Store('accordions'), - id, base; - - if (!Empty(attrs.openFirst)) { - active = 0; - } - else { - // Look in storage for last active panel - if (list) { - id = $(element).attr('id'); - base = ($location.path().replace(/^\//, '').split('/')[0]); - list.every(function(elem) { - if (elem.base === base && elem.id === id) { - active = elem.active; - return false; - } - return true; + $(element).accordion({ + collapsible: true, + heightStyle: "content", + active: active, + activate: function() { + // When a panel is activated update storage + var active = $(this).accordion('option', 'active'), + id = $(this).attr('id'), + base = ($location.path().replace(/^\//, '').split('/')[0]), + list = Store('accordions'), + found = false; + if (!list) { + list = []; + } + list.every(function(elem) { + if (elem.base === base && elem.id === id) { + elem.active = active; + found = true; + return false; + } + return true; + }); + if (found === false) { + list.push({ + base: base, + id: id, + active: active }); } - active = (Empty(active)) ? 0 : active; + Store('accordions', list); + } + }); + }; +}]) + +// Toggle switch inspired by http://www.bootply.com/92189 +.directive('awToggleButton', [function() { + return function(scope, element) { + $(element).click(function() { + var next, choice; + $(this).find('.btn').toggleClass('active'); + if ($(this).find('.btn-primary').size() > 0) { + $(this).find('.btn').toggleClass('btn-primary'); + } + if ($(this).find('.btn-danger').size() > 0) { + $(this).find('.btn').toggleClass('btn-danger'); + } + if ($(this).find('.btn-success').size() > 0) { + $(this).find('.btn').toggleClass('btn-success'); + } + if ($(this).find('.btn-info').size() > 0) { + $(this).find('.btn').toggleClass('btn-info'); + } + $(this).find('.btn').toggleClass('btn-default'); + + // Add data-after-toggle="functionName" to the btn-group, and we'll + // execute here. The newly active choice is passed as a parameter. + if ($(this).attr('data-after-toggle')) { + next = $(this).attr('data-after-toggle'); + choice = $(this).find('.active').text(); + setTimeout(function() { + scope.$apply(function() { + scope[next](choice); + }); + }); } - $(element).accordion({ - collapsible: true, - heightStyle: "content", - active: active, - activate: function() { - // When a panel is activated update storage - var active = $(this).accordion('option', 'active'), - id = $(this).attr('id'), - base = ($location.path().replace(/^\//, '').split('/')[0]), - list = Store('accordions'), - found = false; - if (!list) { - list = []; + }); + }; +}]) + +// +// Support dropping files on an element. Used on credentials page for SSH/RSA private keys +// Inspired by https://developer.mozilla.org/en-US/docs/Using_files_from_web_applications +// +.directive('awDropFile', ['Alert', function(Alert) { + return { + require: 'ngModel', + link: function(scope, element, attrs, ctrl) { + $(element).on('dragenter', function(e) { + e.stopPropagation(); + e.preventDefault(); + }); + $(element).on('dragover', function(e) { + e.stopPropagation(); + e.preventDefault(); + }); + $(element).on('drop', function(e) { + var dt, files, reader; + e.stopPropagation(); + e.preventDefault(); + dt = e.originalEvent.dataTransfer; + files = dt.files; + reader = new FileReader(); + reader.onload = function() { + ctrl.$setViewValue(reader.result); + ctrl.$render(); + ctrl.$setValidity('required', true); + ctrl.$dirty = true; + if (!scope.$$phase) { + scope.$digest(); } - list.every(function(elem) { - if (elem.base === base && elem.id === id) { - elem.active = active; - found = true; - return false; - } - return true; - }); - if (found === false) { - list.push({ - base: base, - id: id, - active: active - }); - } - Store('accordions', list); + }; + reader.onerror = function() { + Alert('Error', 'There was an error reading the selected file.'); + }; + if (files[0].size < 10000) { + reader.readAsText(files[0]); + } else { + Alert('Error', 'There was an error reading the selected file.'); } }); - }; - }]) - - // Toggle switch inspired by http://www.bootply.com/92189 - .directive('awToggleButton', [ function() { - return function(scope, element) { - $(element).click(function() { - var next, choice; - $(this).find('.btn').toggleClass('active'); - if ($(this).find('.btn-primary').size()>0) { - $(this).find('.btn').toggleClass('btn-primary'); - } - if ($(this).find('.btn-danger').size()>0) { - $(this).find('.btn').toggleClass('btn-danger'); - } - if ($(this).find('.btn-success').size()>0) { - $(this).find('.btn').toggleClass('btn-success'); - } - if ($(this).find('.btn-info').size()>0) { - $(this).find('.btn').toggleClass('btn-info'); - } - $(this).find('.btn').toggleClass('btn-default'); - - // Add data-after-toggle="functionName" to the btn-group, and we'll - // execute here. The newly active choice is passed as a parameter. - if ($(this).attr('data-after-toggle')) { - next = $(this).attr('data-after-toggle'); - choice = $(this).find('.active').text(); - setTimeout(function() { - scope.$apply(function() { - scope[next](choice); - }); - }); - } - - }); - }; - }]) - - // - // Support dropping files on an element. Used on credentials page for SSH/RSA private keys - // Inspired by https://developer.mozilla.org/en-US/docs/Using_files_from_web_applications - // - .directive('awDropFile', ['Alert', function(Alert) { - return { - require: 'ngModel', - link: function(scope, element, attrs, ctrl) { - $(element).on('dragenter', function(e) { - e.stopPropagation(); - e.preventDefault(); - }); - $(element).on('dragover', function(e) { - e.stopPropagation(); - e.preventDefault(); - }); - $(element).on('drop', function(e) { - var dt, files, reader; - e.stopPropagation(); - e.preventDefault(); - dt = e.originalEvent.dataTransfer; - files = dt.files; - reader = new FileReader(); - reader.onload = function() { - ctrl.$setViewValue(reader.result); - ctrl.$render(); - ctrl.$setValidity('required',true); - ctrl.$dirty = true; - if (!scope.$$phase) { - scope.$digest(); - } - }; - reader.onerror = function() { - Alert('Error','There was an error reading the selected file.'); - }; - if(files[0].size<10000){ - reader.readAsText(files[0]); - } - else { - Alert('Error','There was an error reading the selected file.'); - } - }); - } - }; - }]); + } + }; +}]); diff --git a/awx/ui/client/src/shared/filters.js b/awx/ui/client/src/shared/filters.js index 2531f1da35..50014c06ce 100644 --- a/awx/ui/client/src/shared/filters.js +++ b/awx/ui/client/src/shared/filters.js @@ -18,6 +18,19 @@ export default angular.module('AWFilters', []) + // Object is empty / undefined / null + .filter('isEmpty', function () { + var key; + return function (obj) { + for (key in obj) { + if (obj.hasOwnProperty(key)) { + return false; + } + } + return true; + }; + }) + // // capitalize -capitalize the first letter of each word // diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 86622485fa..3ed0c224a9 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -67,7 +67,6 @@ * * | Attribute | Description | * | --------- | ----------- | - * | addRequired | true or false. If true, set the required attribute when mode is 'add'. | * | awPopOver | Adds aw-pop-over directive. Set to a string containing the text or html to be evaluated by the directive. | * | awPopOverWatch | Causes the awPopOver directive to add a $scope.$watch on the specified scop variable. When the value of the variable changes the popover text will be updated with the change. | * | awRequiredWhen | Adds aw-required-when directive. Set to an object to be evaluated by the directive. | @@ -79,7 +78,6 @@ * | dataTitle | Used with awPopOver. String value for the title of the popover. | * | default | Default value to place in the field when the form is in 'add' mode. | * | defaultText | Default value to put into a select input. | - * | editRequird | true or false. If true, set the required attribute when mode is 'edit'. | * | falseValue | For radio buttons and checkboxes. Value to set the model to when the checkbox or radio button is not selected. | * | genMD5 | true or false. If true, places the field in an input group with a button that when clicked replaces the field contents with an MD5 has key. Used with host_config_key on the job templates detail page. | * | integer | Adds the integer directive to validate that the value entered is of type integer. Add min and max to supply lower and upper range bounds to the entered value. | @@ -88,7 +86,9 @@ * | ngClick | Adds ng-click directive. Set to the JS expression to be evaluated by ng-click. | * | ngHide | Adds ng-hide directive. Set to the JS expression to be evaluated by ng-hide. | * | ngShow | Adds ng-show directive. Set to the JS expression to be evaluated by ng-show. | + * | readonly | Defaults to false. When true the readonly attribute is set, disallowing changes to field content. | + * | required | boolean. Adds required flag to form field | * | rows | Integer value used to set the row attribute for a textarea. | * | sourceModel | Used in conjunction with sourceField when the data for the field is part of the summary_fields object returned by the API. Set to the name of the summary_fields object that contains the field. For example, the job_templates object returned by the API contains summary_fields.inventory. | * | sourceField | String containing the summary_field.object.field name from the API summary_field object. For example, if a fields should be associated to the summary_fields.inventory.name, set the sourceModel to 'inventory' and the sourceField to 'name'. | @@ -140,13 +140,12 @@ export default angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerator.name]) .factory('GenerateForm', ['$rootScope', '$location', '$compile', 'generateList', - 'SearchWidget', 'PaginateWidget', 'Attr', 'Icon', 'Column', + 'Attr', 'Icon', 'Column', 'NavigationLink', 'HelpCollapse', 'DropDown', 'Empty', 'SelectIcon', - 'Store', 'ActionButton', 'getSearchHtml', 'i18n', - function ($rootScope, $location, $compile, GenerateList, SearchWidget, - PaginateWidget, Attr, Icon, Column, NavigationLink, HelpCollapse, - DropDown, Empty, SelectIcon, Store, ActionButton, getSearchHtml, - i18n) { + 'Store', 'ActionButton', '$log', 'i18n', + function ($rootScope, $location, $compile, GenerateList, + Attr, Icon, Column, NavigationLink, HelpCollapse, + DropDown, Empty, SelectIcon, Store, ActionButton, $log, i18n) { return { setForm: function (form) { this.form = form; }, @@ -162,204 +161,15 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat has: function (key) { return (this.form[key] && this.form[key] !== null && this.form[key] !== undefined) ? true : false; }, - - inject: function (form, options) { - // - // Use to inject the form as html into the view. View MUST have an ng-bind for 'htmlTemplate'. - // Returns scope of form. - // - - var element, fld, set, show, self = this; - - if (options.modal) { - if (options.modal_body_id) { - element = angular.element(document.getElementById(options.modal_body_id)); - } else { - // use default dialog - element = angular.element(document.getElementById('form-modal-body')); - } - } else { - if (options.id) { - element = angular.element(document.getElementById(options.id)); - } else { - element = angular.element(document.getElementById('htmlTemplate')); - } - } - - this.mode = options.mode; - this.modal = (options.modal) ? true : false; - this.setForm(form); - - if (options.html) { - element.html(options.html); - } else { - element.html(this.build(options)); - } - - if (options.scope) { - this.scope = options.scope; - } else { - this.scope = element.scope(); - } - - if (options.mode) { - this.scope.mode = options.mode; - } - - if(options.mode === 'edit' && this.form.related && - !_.isEmpty(this.form.related)){ - var tabs = [this.form.name], that = this; - tabs.push(Object.keys(this.form.related)); - tabs = _.flatten(tabs); - _.map(tabs, function(itm){ - that.scope.$parent[itm+"Selected"] = false; - }); - this.scope.$parent[this.form.name+"Selected"] = true; - - - this.scope.$parent.toggleFormTabs = function($event){ - _.map(tabs, function(itm){ - that.scope.$parent[itm+"Selected"] = false; - }); - that.scope.$parent[$event.target.id.split('_tab')[0]+"Selected"] = true; - }; - - } - - for (fld in form.fields) { - this.scope[fld + '_field'] = form.fields[fld]; - this.scope[fld + '_field'].name = fld; - } - - for (fld in form.headerFields){ - this.scope[fld + '_field'] = form.headerFields[fld]; - this.scope[fld + '_field'].name = fld; - } - - $compile(element)(this.scope); - - if (!options.html) { - // Reset the scope to prevent displaying old data from our last visit to this form - for (fld in form.fields) { - this.scope[fld] = null; - } - for (set in form.related) { - this.scope[set] = null; - } - if (((!options.modal) && options.related) || this.form.forceListeners) { - this.addListeners(); - } - if (options.mode === 'add') { - this.applyDefaults(); - } - } - - // Remove any lingering tooltip and popover
elements - $('.tooltip').each(function () { - $(this).remove(); - }); - - $('.popover').each(function () { - // remove lingering popover
. Seems to be a bug in TB3 RC1 - $(this).remove(); - }); - - // Prepend an asterisk to required field label - $('.form-control[required], input[type="radio"][required]').each(function () { - var label, span; - if (Empty($(this).attr('aw-required-when'))) { - label = $(this).closest('.form-group').find('label').first(); - if (label.length > 0) { - span = label.children('span'); - if (span.length > 0 && !span.first().hasClass('prepend-asterisk')) { - span.first().addClass('prepend-asterisk'); - } else if (span.length <= 0 && !label.first().hasClass('prepend-asterisk')) { - label.first().addClass('prepend-asterisk'); - } - } - } - }); - - try { - $('#help-modal').empty().dialog('destroy'); - } catch (e) { - //ignore any errors should the dialog not be initialized - } - - if (options.modal) { - $rootScope.flashMessage = null; - this.scope.formModalActionDisabled = false; - this.scope.formModalInfo = false; //Disable info button for default modal - if (form) { - if (options.modal_title_id) { - this.scope[options.modal_title_id] = (options.mode === 'add') ? form.addTitle : form.editTitle; - } else { - this.scope.formModalHeader = (options.mode === 'add') ? form.addTitle : form.editTitle; //Default title for default modal - } - } - if (options.modal_selector) { - $(options.modal_selector).modal({ - show: true, - backdrop: 'static', - keyboard: true - }); - $(options.modal_selector).on('shown.bs.modal', function () { - $(options.modal_select + ' input:first').focus(); - }); - $(options.modal_selector).on('hidden.bs.modal', function () { - $('.tooltip').each(function () { - // Remove any lingering tooltip and popover
elements - $(this).remove(); - }); - - $('.popover').each(function () { - // remove lingering popover
. Seems to be a bug in TB3 RC1 - $(this).remove(); - }); - }); - } else { - show = (options.show_modal === false) ? false : true; - $('#form-modal').modal({ - show: show, - backdrop: 'static', - keyboard: true - }); - $('#form-modal').on('shown.bs.modal', function () { - $('#form-modal input:first').focus(); - }); - $('#form-modal').on('hidden.bs.modal', function () { - $('.tooltip').each(function () { - // Remove any lingering tooltip and popover
elements - $(this).remove(); - }); - - $('.popover').each(function () { - // remove lingering popover
. Seems to be a bug in TB3 RC1 - $(this).remove(); - }); - }); - } - $(document).bind('keydown', function (e) { - if (e.keyCode === 27) { - if (options.modal_selector) { - $(options.modal_selector).modal('hide'); - } - $('#prompt-modal').modal('hide'); - $('#form-modal').modal('hide'); - } - }); - } - - if (self.scope && !self.scope.$$phase) { - setTimeout(function() { - if (self.scope) { - self.scope.$digest(); - } - }, 100); - } - - return self.scope; - + // Not a very good way to do this + // Form sub-states expect to target ui-views related@stateName & modal@stateName + // Also wraps mess of generated HTML in a .Panel + wrapPanel(html){ + return `
+ ${html} +
+
+
`; }, buildHTML: function(form, options) { @@ -373,13 +183,13 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat return this.build(options); }, - applyDefaults: function () { - for (var fld in this.form.fields) { - if (this.form.fields[fld]['default'] || this.form.fields[fld]['default'] === 0) { - if (this.form.fields[fld].type === 'select' && this.scope[fld + '_options']) { - this.scope[fld] = this.scope[fld + '_options'][this.form.fields[fld]['default']]; + applyDefaults: function (form, scope) { + for (var fld in form.fields) { + if (form.fields[fld]['default'] || form.fields[fld]['default'] === 0) { + if (form.fields[fld].type === 'select' && scope[fld + '_options']) { + scope[fld] = scope[fld + '_options'][form.fields[fld]['default']]; } else { - this.scope[fld] = this.form.fields[fld]['default']; + scope[fld] = form.fields[fld]['default']; } } } @@ -477,89 +287,6 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } }, - addListeners: function () { - - if (this.modal) { - $('.jqui-accordion-modal').accordion({ - collapsible: false, - heightStyle: 'content', - active: 0 - }); - } else { - // For help collapse, toggle the plus/minus icon - this.scope.accordionToggle = function (selector) { - $(selector).collapse('toggle'); - if ($(selector + '-icon').hasClass('fa-minus')) { - $(selector + '-icon').removeClass('fa-minus').addClass('fa-plus'); - } else { - $(selector + '-icon').removeClass('fa-plus').addClass('fa-minus'); - } - }; - - $('.jqui-accordion').each(function () { - var active = false, - list = Store('accordions'), - found = false, - id, base, i; - - if ($(this).attr('data-open-first')) { - active = 0; - } - else { - if (list) { - id = $(this).attr('id'); - base = ($location.path().replace(/^\//, '').split('/')[0]); - for (i = 0; i < list.length && found === false; i++) { - if (list[i].base === base && list[i].id === id) { - found = true; - active = list[i].active; - } - } - } - if (found === false && $(this).attr('data-open') === 'true') { - active = 0; - } - } - - $(this).accordion({ - collapsible: true, - heightStyle: 'content', - active: active, - activate: function () { - // Maintain in local storage of list of all accordions by page, recording - // the active panel for each. If user navigates away and comes back, - // we can activate the last panely viewed. - $('.jqui-accordion').each(function () { - var active = $(this).accordion('option', 'active'), - id = $(this).attr('id'), - base = ($location.path().replace(/^\//, '').split('/')[0]), - list = Store('accordions'), - found = false, - i; - if (!list) { - list = []; - } - for (i = 0; i < list.length && found === false; i++) { - if (list[i].base === base && list[i].id === id) { - found = true; - list[i].active = active; - } - } - if (found === false) { - list.push({ - base: base, - id: id, - active: active - }); - } - Store('accordions', list); - }); - } - }); - }); - } - }, - genID: function () { var id = new Date(); return id.getTime(); @@ -632,7 +359,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat buildField: function (fld, field, options, form) { - var i, fldWidth, offset, html = '', + var i, fldWidth, offset, html = '', error_message, horizontal = (this.form.horizontal) ? true : false; function getFieldWidth() { @@ -719,13 +446,14 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if (field.label || field.labelBind) { html += "
`; } if (field.awPassMatch) { - var error_message = i18n._("This value does not match the password you entered previously. Please confirm that password."); + error_message = i18n._('This value does not match the password you entered previously. Please confirm that password.'); html += "
" + - error_message + "
\n"; + `.$error.awpassmatch">${error_message}
`; } if (field.awValidUrl) { - var error_message = i18n._("Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."); + error_message = i18n._("Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."); html += "
" + - error_message + "
\n"; + `.$error.awvalidurl">${error_message}
`; } html += "
\n"; @@ -914,6 +637,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "\t" + label(); if (field.hasShowInputButton) { + var tooltip = i18n._("Toggle the display of plaintext."); field.toggleInput = function(id) { var buttonId = id + "_show_input_button", inputId = id + "_input", @@ -926,7 +650,6 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat $(inputId).attr("type", "password"); } }; - var tooltip = i18n._("Toggle the display of plaintext."); html += "\
\n"; @@ -934,7 +657,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "\n"; html += "
\n"; } if (field.type === "email") { - var error_message = i18n._("Please enter a valid email address."); + error_message = i18n._("Please enter a valid email address."); html += "
\n" + - error_message + "\n
\n"; + this.form.name + "_form." + fld + `.$error.email'>${error_message}
`; } if (field.awPassMatch) { - var error_message = i18n._("This value does not match the password you entered previously. Please confirm that password."); + error_message = error_message = i18n._("This value does not match the password you entered previously. Please confirm that password."); html += "
\n" + - error_message + "\n
\n"; + `.$error.awpassmatch'>${error_message}
`; } if (field.awValidUrl) { - var error_message = i18n._("Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."); + error_message = i18n._("Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."); html += "
\n" + - error_message + "\n
\n"; + `.$error.awvalidurl'>${error_message}
`; } if (field.chkPass && $AnsibleConfig) { // password strength if ($AnsibleConfig.password_length) { - var error_message = i18n.format(i18n._("Your password must be %d characters long."), $AnsibleConfig.password_length); + error_message = i18n.format(i18n._("Your password must be %d characters long."), $AnsibleConfig.password_length); html += "
" + - error_message + "
\n"; + `.$error.password_length">${error_message}
`; } if ($AnsibleConfig.password_hasLowercase) { - var error_message = i18n._("Your password must contain a lowercase letter."); + error_message = i18n._("Your password must contain a lowercase letter."); html += "
" + - error_message + "
\n"; + `.$error.hasLowercase">${error_message}
`; } if ($AnsibleConfig.password_hasUppercase) { - var error_message = i18n._("Your password must contain an uppercase letter."); + error_message = i18n._("Your password must contain an uppercase letter."); html += "
" + - error_message + "
\n"; + `.$error.hasUppercase">${error_message}
`; } if ($AnsibleConfig.password_hasNumber) { - var error_message = i18n._("Your password must contain a number."); + error_message = i18n._("Your password must contain a number."); html += "
" + - error_message + "
\n"; + `.$error.hasNumber">${error_message}
`; } if ($AnsibleConfig.password_hasSymbol) { - var error_message = i18n.format(i18n._("Your password must contain one of the following characters: %s"), "`~!@#$%^&*()_-+=|}\]{\[;:\"\'?\/>.<,"); + i18n.format(i18n._("Your password must contain one of the following characters: %s"), "`~!@#$%^&*()_-+=|}\]{\[;:\"\'?\/>.<,"); html += "
" + - error_message + "
\n"; + `.$error.hasSymbol">${error_message}
`; } } @@ -1089,8 +802,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += buildId(field, fld, this.form); html += (field.placeholder) ? this.attr(field, 'placeholder') : ""; html += (field.ngDisabled) ? this.attr(field, 'ngDisabled'): ""; - html += (options.mode === 'edit' && field.editRequired) ? "required " : ""; - html += (options.mode === 'add' && field.addRequired) ? "required " : ""; + html += (field.required) ? "required " : ""; html += (field.ngRequired) ? "ng-required=\"" + field.ngRequired +"\"" : ""; html += (field.readonly || field.showonly) ? "readonly " : ""; html += (field.awDropFile) ? "aw-drop-file " : ""; @@ -1119,7 +831,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } // Add error messages - if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired)) { + if (field.required) { html += "
" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please enter a value.") + "
\n"; } @@ -1148,8 +860,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += (field.ngRequired) ? this.attr(field, 'ngRequired') : ""; html += (field.ngInit) ? this.attr(field, 'ngInit') : ""; html += buildId(field, fld, this.form); - html += (options.mode === 'edit' && field.editRequired) ? "required " : ""; - html += (options.mode === 'add' && field.addRequired) ? "required " : ""; + html += (field.required) ? "required " : ""; //used for select2 combo boxes html += (field.multiSelect) ? "multiple " : ""; html += (field.readonly) ? "disabled " : ""; @@ -1162,13 +873,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if(!field.multiSelect && !field.disableChooseOption){ html += "\n"; } @@ -1193,8 +898,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } // Add error messages - if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired) || - field.awRequiredWhen) { + if (field.required || field.awRequiredWhen) { html += "
" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please select a value."); if (field.includePlaybookNotFoundError) { @@ -1237,8 +941,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += (field.ngChange) ? this.attr(field, 'ngChange') : ""; html += (field.ngDisabled) ? "ng-disabled=\"" + field.ngDisabled + "\" " : ""; html += (field.slider) ? "id=\"" + fld + "-number\"" : (field.id) ? this.attr(field, 'id') : ""; - html += (options.mode === 'edit' && field.editRequired) ? "required " : ""; - html += (options.mode === 'add' && field.addRequired) ? "required " : ""; + html += (field.required) ? "required " : ""; html += (field.readonly) ? "readonly " : ""; html += (field.integer) ? "integer " : ""; html += (field.disabled) ? "data-disabled=\"true\" " : ""; @@ -1264,7 +967,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } // Add error messages - if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired)) { + if (field.required) { html += "
" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please select a value.") + "
\n"; } @@ -1297,7 +1000,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += buildCheckbox(this.form, field.fields[i], field.fields[i].name, i); } // Add error messages - if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired)) { + if (field.required) { html += "
" + (field.requiredErrorMsg ? field.requiredErrorMsg : "Please select at least one value.") + "
\n"; } @@ -1365,13 +1068,12 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "ng-model=\"" + fld + "\" "; html += (field.ngChange) ? this.attr(field, 'ngChange') : ""; html += (field.readonly) ? "disabled " : ""; - html += (options.mode === 'edit' && field.editRequired) ? "required " : ""; - html += (options.mode === 'add' && field.addRequired) ? "required " : ""; + html += (field.required) ? "required " : ""; html += (field.ngDisabled) ? this.attr(field, 'ngDisabled') : ""; html += " > " + field.options[i].label + "\n"; html += "\n"; } - if ((options.mode === 'add' && field.addRequired) || (options.mode === 'edit' && field.editRequired)) { + if (field.required) { html += "
Please select a value.
\n"; @@ -1420,19 +1122,19 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat //lookup type fields if (field.type === 'lookup') { - + let defaultLookupNgClick = `$state.go($state.current.name + '.${field.sourceModel}')`; html += label(); html += "
\n"; + html += `
`; html += "\n"; - html += "\n"; + html += ``; html += "\n"; html += ""; html += (options.mode === 'edit') ? this.form.editTitle : this.form.addTitle; if(this.form.name === "user"){ - var user_str = i18n._("Admin"); html+= "" + - user_str + ""; - user_str = i18n._("Auditor"); + "ng-show='is_superuser'>Admin"; html+= "" + - user_str + ""; + "ng-show='is_system_auditor'>Auditor"; html+= "LDAP"; html+= ""; html += "
\n"; } - html += "
\n"; //end of Form-header + html += "
"; //end of Form-header } if (!_.isEmpty(this.form.related)) { - var collection; - // i18n is used with src/forms/Projects.js - var details = i18n._("Details"); - html += "
"; + var collection, details = i18n._('Details'); + html += `
`; if(this.mode === "edit"){ - html += "
" + - details + "
"; + html += `
` + + `${details}
`; for (itm in this.form.related) { collection = this.form.related[itm]; - html += `
" + - details + "
"; + `class="Form-tab is-selected">${details}
`; for (itm in this.form.related) { collection = this.form.related[itm]; @@ -1603,8 +1300,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } if(!_.isEmpty(this.form.related) && this.mode === "edit"){ - html += "
"; + html += `
`; } html += "\n"; html += "
{{ flashMessage }}
\n"; - if (this.form.licenseTabs) { - html += "
    \n"; - for (i = 0; i < this.form.licenseTabs.length; i++) { - tab = this.form.licenseTabs[i]; - html += "" + tab.label + "\n"; - } - html += "
\n"; - html += "
\n"; - for (i = 0; i < this.form.licenseTabs.length; i++) { - tab = this.form.licenseTabs[i]; - html += "
\n"; - for (fld in this.form.fields) { - if (this.form.fields[fld].tab === tab.name) { - html += this.buildField(fld, this.form.fields[fld], options, this.form); - } - } - html += "
\n"; - } - html += "
\n"; - } else { var currentSubForm; var hasSubFormField; // original, single-column form @@ -1690,6 +1357,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } html += this.buildField(fld, field, options, this.form); + // console.log('*********') + // console.log(html) } } @@ -1703,7 +1372,6 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat if (group !== '') { html += "
\n"; } - } html += "\n"; @@ -1714,7 +1382,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "
"; - html += this.GenerateCollection({ form: this.form, related: itm }, options); - html += "
\n"; - } - } - } } - - return html; + if (this.form.include){ + _.forEach(this.form.include, (template) =>{ + html += `
`; + }); + } + // console.log(html) + return this.wrapPanel(html); }, - buildCollections: function (options) { - // - // Create TB accordians with imbedded lists for related collections - // Should not be called directly. Called internally by build(). - // - var form = this.form, - html = '', - itm, collection; + buildCollection: function (params) { + // Currently, there are two ways we reference a list definition in a form + // Permissions lists are defined with boilerplate JSON in model.related + // this.GenerateCollection() is shaped around supporting this definition + // Notifications lists contain a reference to the NotificationList object, which contains the list's JSON definition + // However, Notification Lists contain fields that are only rendered by with generateList.build's chain + // @extendme rip out remaining HTML-concat silliness and directivize ¯\_(ツ)_/¯ + this.form = params.form; + var html = '', + collection = this.form.related[params.related]; - if (!options.collapseAlreadyStarted) { - html = "
\n"; - } - - for (itm in form.related) { - collection = form.related[itm]; - html += "

" + (collection.title || collection.editTitle) + "

\n"; - html += "
\n"; if (collection.generateList) { - html += GenerateList.buildHTML(collection, { mode: 'edit' }); + html += GenerateList.build({ mode: params.mode, list: collection}); } else { - html += this.GenerateCollection({ form: form, related: itm }, options); + html += this.GenerateCollection({ form: this.form, related: params.related }, {mode: params.mode}); } - html += "
\n"; // accordion inner - } - - if (!options.collapseAlreadyStarted) { - html += "
\n"; // accordion body - } return html; }, @@ -1869,13 +1517,6 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "Hint: " + collection.instructions + "\n"; html += "
\n"; } - var rootID = $location.$$path.split("/")[2]; - var endpoint = (collection.basePath) ? "/api/v1/" + - collection.basePath - .replace(":id", rootID) : ""; - var tagSearch = getSearchHtml - .inject(getSearchHtml.getList(collection), - endpoint, itm, collection.iterator); var actionButtons = ""; Object.keys(collection.actions || {}) @@ -1884,7 +1525,6 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat .actions[act]); }); var hideOnSuperuser = (hideOnSuperuser === true) ? true : false; - if(actionButtons.length === 0 ){ // The search bar should be full width if there are no // action buttons @@ -1893,38 +1533,37 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat else { width = "col-lg-8 col-md-8 col-sm-8 col-xs-12"; } + + // smart-search directive html += ` -
-
0) && - !(is_superuser && ${hideOnSuperuser})\"> - ${tagSearch} -
`; +
+ + +
+ `; if(actionButtons.length>0){ - html += `
-
+ html += `
${actionButtons} -
-
`; +
`; } - html += "
"; + //html += "
"; // Message for when a search returns no results. This should only get shown after a search is executed with no results. - html += `
-
+ class="row" + ng-show="${itm}.length === 0 && !(${collection.iterator}_searchTags | isEmpty)"> +
No records matched your search.
@@ -1932,61 +1571,35 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat // Show the "no items" box when loading is done and the user isn't actively searching and there are no results var emptyListText = (collection.emptyListText) ? collection.emptyListText : "PLEASE ADD ITEMS TO THIS LIST"; - html += '
'; - html += "
" + emptyListText + "
"; + html += `
`; + html += `
${emptyListText}
`; html += '
'; html += ` -
+
System Administrators have access to all ${collection.iterator}s
`; // Start the list html += ` -
0)) && - !(is_superuser && ${collection.hideOnSuperuser})\"> - +
+
- + `; html += (collection.index === undefined || collection.index !== false) ? "\n" : ""; for (fld in collection.fields) { - if (!collection.fields[fld].searchOnly) { - - html += "\n"; - } + html += ``; } if (collection.fieldActions) { html += "\n"; @@ -2063,12 +1676,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat //html += "\n"; // close well html += "\n"; // close list-wrapper div - html += PaginateWidget({ - set: itm, - iterator: collection.iterator, - mini: true, - hideOnSuperuser: collection.hideOnSuperuser - }); + html += ``; return html; } }; diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index 6d6b4b70fc..e1697ca25c 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -676,175 +676,13 @@ angular.module('GeneratorHelpers', [systemStatus.name]) }; }) -.factory('SearchWidget', function () { - return function (params) { - // - // Generate search widget - // - var iterator = params.iterator, - form = params.template, - size = params.size, - mini = params.mini, - includeSize = (params.includeSize === undefined) ? true : params.includeSize, - ngShow = (params.ngShow) ? params.ngShow : false, - i, html = '', - modifier, - searchWidgets = (params.searchWidgets) ? params.searchWidgets : 1, - sortedKeys; - - function addSearchFields(idx) { - var html = ''; - sortedKeys = Object.keys(form.fields).sort(); - sortedKeys.forEach(function(fld) { - if ((form.fields[fld].searchable === undefined || form.fields[fld].searchable === true) && - (((form.fields[fld].searchWidget === undefined || form.fields[fld].searchWidget === 1) && idx === 1) || - (form.fields[fld].searchWidget === idx))) { - html += "
  • " + - form.fields[fld].searchLabel + "
  • \n"; - } else { - html += form.fields[fld].label.replace(/
    /g, ' ') + "', " + idx + ")\">" + - form.fields[fld].label.replace(/
    /g, ' ') + "\n"; - } - } - }); - return html; - } - - for (i = 1; i <= searchWidgets; i++) { - modifier = (i === 1) ? '' : i; - - if (includeSize) { - html += "
    \n"; - } - - if(ngShow) { - html += "
    "; - } - - html += "
    \n"; - html += "
    \n"; - html += "\n"; - html += "
      \n"; - html += addSearchFields(i); - html += "
    \n"; - html += "
    \n"; - - html += "\n"; - - html += "\n"; - - // Reset button for drop-down - html += "
    \n"; - html += "\n"; - html += "
    \n"; - - html += "
    \n"; - - html += "\n"; - - html += "\n"; - - html += "
    \n"; - - if(ngShow) { - html += "
    "; - } - - if (includeSize) { - html += "
    \n"; - } - } - - return html; - - }; -}) - -.factory('PaginateWidget', [ - function () { - return function (params) { - var iterator = params.iterator, - set = params.set, - hideOnSuperuser = (params.hideOnSuperuser) ? true : false, - html = ''; - html += "\n"; - html += ` -
    - `; - html += "
    "; - html += "
    "; - html += "
      \n"; - html += "
    • " + - "
    • \n"; - - html += "
    • " + - "
    • \n"; - - // html += "
    • " + - // "{{ page }}
    • \n"; - html += "
    • " + - "{{ page }}
    • \n"; - - html += "
    • " + iterator + "_num_pages\">
    • \n"; - - html += "
    • = " + iterator + "_num_pages\">
    • \n"; - html += "
    \n"; - html += "Page {{ " + iterator + "_page }} of {{ " + iterator + "_num_pages }}"; - html += "
    "; - html += "
    "; - html += "
    \n"; - html += "ITEMS "; - html += "{{ (" + iterator + "_total_rows | number:0) < 1 ? 0 : (" + iterator + "_page-1)*" + iterator + "_page_size+1}}"; - html += "–{{ (" + iterator + "_total_rows | number:0) < (" + iterator + "_page)*" + iterator + "_page_size ? (" + iterator + "_total_rows | number:0) : (" + iterator + "_page)*" + iterator + "_page_size}}"; - html += " OF "; - html += "{{ " + iterator + "_total_rows | number:0 }}"; - html += ""; - html += "
    \n"; - html += "
    \n"; - - return html; - }; - } -]) .factory('ActionButton', function () { return function (options) { var html = ''; + console.log(options) + html += '\n"; - } - html += "
    "; - html += "
    "; - html += "
    \n"; - - for (action in list.actions) { - list.actions[action] = _.defaults(list.actions[action], { dataPlacement: "top" }); - } - - html += "
    "; - if(list.toolbarAuxAction) { - html += "
    "; - html += list.toolbarAuxAction; - html += "
    "; - } - html += "\n
    "; - html += "
    "; - html += ""; - } } + html += ""; + if (options.cancelButton === true) { + html += "
    "; + html += "
    \n"; + } + html += "
    "; + html += "
    "; + html += `
    `; - if (options.mode === 'edit' && list.editInstructions) { - html += "
    \n"; - html += "\n"; - html += "Hint: " + list.editInstructions + "\n"; - html += "
    \n"; - } + for (action in list.actions) { + list.actions[action] = _.defaults(list.actions[action], { dataPlacement: "top" }); + } - if (options.instructions) { - html += "
    " + options.instructions + "
    \n"; - } - else if (list.instructions) { - html += "
    " + list.instructions + "
    \n"; + html += "
    "; + if (list.toolbarAuxAction) { + html += "
    "; + html += list.toolbarAuxAction; + html += "
    "; + } + html += "\n
    "; + html += "
    "; + html += ""; } + } - if (options.mode !== 'lookup' && (list.well === undefined || list.well)) { - html += "
    \n"; - } - html += (list.searchRowActions) ? "
    " : ""; - if (list.searchRowActions && !list.searchSize) { - list.searchSize = 'col-lg-7 col-md-12 col-sm-12 col-xs-12'; - } - if (options.showSearch=== undefined || options.showSearch === true) { - var tagSearch = getSearchHtml - .inject(getSearchHtml.getList(list), - getSearchHtml.getEndpoint(list), - list.name, - list.iterator); - html += ` -
    - ${tagSearch} -
    + if (options.mode === 'edit' && list.editInstructions) { + html += "
    \n"; + html += "\n"; + html += "Hint: " + list.editInstructions + "\n"; + html += "
    \n"; + } + + if (options.instructions) { + html += "
    " + options.instructions + "
    \n"; + } else if (list.instructions) { + html += "
    " + list.instructions + "
    \n"; + } + + if (options.mode !== 'lookup' && (list.well === undefined || list.well)) { + html += `
    `; + } + + html += (list.searchRowActions) ? "
    " : ""; + if (list.searchRowActions && !list.searchSize) { + list.searchSize = 'col-lg-7 col-md-12 col-sm-12 col-xs-12'; + } + if (options.showSearch === undefined || options.showSearch === true) { + html += ` +
    + + +
    `; - } - if(list.searchRowActions) { - html += "
    "; + } + if (list.searchRowActions) { + html += "
    "; - var actionButtons = ""; - Object.keys(list.searchRowActions || {}) - .forEach(act => { - actionButtons += ActionButton(list.searchRowActions[act]); - }); - html += ` + var actionButtons = ""; + Object.keys(list.searchRowActions || {}) + .forEach(act => { + actionButtons += ActionButton(list.searchRowActions[act]); + }); + html += `
    ${actionButtons}
    `; - html += "
    "; + html += "
    "; + } + + if (options.showSearch !== false) { + // Message for when a search returns no results. This should only get shown after a search is executed with no results. + html +=` +
    +
    No records matched your search.
    +
    + `; + } + + // Show the "no items" box when loading is done and the user isn't actively searching and there are no results + html += `
    `; + html += (list.emptyListText) ? list.emptyListText : i18n._("PLEASE ADD ITEMS TO THIS LIST"); + html += "
    "; + + // Add a title and optionally a close button (used on Inventory->Groups) + if (options.mode !== 'lookup' && list.showTitle) { + html += "
    "; + html += (options.mode === 'edit' || options.mode === 'summary') ? list.editTitle : list.addTitle; + html += "
    \n"; + } + + // table header row + html += "
    0\""; + html += (list.awCustomScroll) ? " aw-custom-scroll " : ""; + html += ">\n"; + + function buildTable() { + var extraClasses = list['class']; + var multiSelect = list.multiSelect ? 'multi-select-list' : null; + var multiSelectExtended = list.multiSelectExtended ? 'true' : 'false'; + + if (options.mode === 'summary') { + extraClasses += ' table-summary'; } - if (options.showSearch=== undefined || options.showSearch === true) { - // Message for when a search returns no results. This should only get shown after a search is executed with no results. - html += "
    \n"; - html += "
    No records matched your search.
    \n"; - html += "
    \n"; + return $('
    #"; - } else { - html += ">"; - } - - - html += collection.fields[fld].label; - - if (!(collection.fields[fld].noSort || collection.fields[fld].nosort)) { - html += " "; - } - - html += " + ${collection.fields[fld].label} + Actions
    ') + .attr('id', list.name + '_table') + .addClass('List-table') + .addClass(extraClasses) + .attr('multi-select-list', multiSelect) + .attr('is-extended', multiSelectExtended); + + } + + var table = buildTable(); + var innerTable = ''; + + if (!options.skipTableHead) { + innerTable += this.buildHeader(options); + } + + // table body + // gotcha: transcluded elements require custom scope linking - binding to $parent models assumes a very rigid DOM hierarchy + // see: lookup-modal.directive.js for example + innerTable += options.mode === 'lookup' ? `` : `"\n"`; + innerTable += "\n"; + + if (list.index) { + innerTable += "\n"; + } + + if (list.multiSelect) { + innerTable += ''; + } + + // Change layout if a lookup list, place radio buttons before labels + if (options.mode === 'lookup') { + if (options.input_type === "radio") { //added by JT so that lookup forms can be either radio inputs or check box inputs + innerTable += ``; + } else { // its assumed that options.input_type = checkbox + innerTable += ""; } + } - // Show the "no items" box when loading is done and the user isn't actively searching and there are no results - html += "
    "; - html += (list.emptyListText) ? list.emptyListText : i18n._("PLEASE ADD ITEMS TO THIS LIST"); - html += "
    "; - - // Add a title and optionally a close button (used on Inventory->Groups) - if (options.mode !== 'lookup' && list.showTitle) { - html += "
    "; - html += (options.mode === 'edit' || options.mode === 'summary') ? list.editTitle : list.addTitle; - html += "
    \n"; + cnt = 2; + base = (list.base) ? list.base : list.name; + base = base.replace(/^\//, ''); + for (fld in list.fields) { + cnt++; + if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && + !(options.mode === 'lookup' && list.fields[fld].excludeModal === true)) { + innerTable += Column({ + list: list, + fld: fld, + options: options, + base: base + }); } + } - // table header row - html += "
    0)\""; - html += (list.awCustomScroll) ? " aw-custom-scroll " : ""; - html += ">\n"; - - function buildTable() { - var extraClasses = list['class']; - var multiSelect = list.multiSelect ? 'multi-select-list' : null; - var multiSelectExtended = list.multiSelectExtended ? 'true' : 'false'; - - if (options.mode === 'summary') { - extraClasses += ' table-summary'; - } - - return $('
    {{ $index + ((" + list.iterator + "_page - 1) * " + list.iterator + "_page_size) + 1 }}.
    ') - .attr('id', list.name + '_table') - .addClass('List-table') - .addClass(extraClasses) - .attr('multi-select-list', multiSelect) - .attr('is-extended', multiSelectExtended); - - } - - var table = buildTable(); - var innerTable = ''; - - if (!options.skipTableHead) { - innerTable += this.buildHeader(options); - } - - // table body - innerTable += "\n"; - innerTable += "\n"; - - if (list.index) { - innerTable += "\n"; - } - - if (list.multiSelect) { - innerTable += ''; - } - - // Change layout if a lookup list, place radio buttons before labels - if (options.mode === 'lookup') { - if(options.input_type==="radio"){ //added by JT so that lookup forms can be either radio inputs or check box inputs - innerTable += ""; - } - else { // its assumed that options.input_type = checkbox - innerTable += ""; - } - } - - cnt = 2; - base = (list.base) ? list.base : list.name; - base = base.replace(/^\//, ''); - for (fld in list.fields) { - cnt++; - if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && - !(options.mode === 'lookup' && list.fields[fld].excludeModal === true)) { - innerTable += Column({ - list: list, - fld: fld, - options: options, - base: base - }); - } - } - - if (options.mode === 'select') { - if(options.input_type==="radio"){ //added by JT so that lookup forms can be either radio inputs or check box inputs - innerTable += ""; - } - else { // its assumed that options.input_type = checkbox - innerTable += ""; - } - } else if ((options.mode === 'edit' || options.mode === 'summary') && list.fieldActions) { + } + } else if ((options.mode === 'edit' || options.mode === 'summary') && list.fieldActions) { - // Row level actions + // Row level actions - innerTable += "\n"; } - - innerTable += "\n"; - - // Message for loading - innerTable += "\n"; - var loading = i18n._("Loading..."); - innerTable += "\n"; - innerTable += "\n"; - - // End List - innerTable += "\n"; - - table.html(innerTable); - html += table.prop('outerHTML'); - - html += "\n"; - - if (options.mode === 'select' && (options.selectButton === undefined || options.selectButton)) { - html += "
    \n"; - html += " \n"; - html += "
    \n"; - } - - if (options.mode !== 'lookup' && (list.well === undefined || list.well === true)) { - html += "\n"; //well - } - - if (options.mode === 'lookup' || (options.id && options.id === "form-modal-body")) { - html += PaginateWidget({ - set: list.name, - iterator: list.iterator - }); - } else { - html += PaginateWidget({ - set: list.name, - iterator: list.iterator - }); - } - - return html; - }, - - buildHeader: function(options) { - var list = this.list, - fld, html; - - function buildSelectAll() { - return $('\n"; - html += "\n"; - if (list.index) { - html += "\n"; - } - - if (list.multiSelect) { - html += buildSelectAll().prop('outerHTML'); - } - else if (options.mode === 'lookup') { - html += ""; - } - for (fld in list.fields) { - if ((list.fields[fld].searchOnly === undefined || list.fields[fld].searchOnly === false) && - !(options.mode === 'lookup' && list.fields[fld].excludeModal === true)) { - html += "\n"; - } - } - if (options.mode === 'select') { - html += ""; - } else if (options.mode === 'edit' && list.fieldActions) { - html += "\n"; - } - html += "\n"; - html += "\n"; - return html; + innerTable += "\n"; } - }; - }]; + + innerTable += "\n"; + + // End List + innerTable += "\n"; + + table.html(innerTable); + html += table.prop('outerHTML'); + + html += "\n"; + + if (options.mode === 'select' && (options.selectButton === undefined || options.selectButton)) { + html += "
    \n"; + html += " \n"; + html += "
    \n"; + } + + html += ` + `; + + return html; + }, + + buildHeader: function(options) { + var list = this.list, + fld, html; + + function buildSelectAll() { + return $('\n"; + html += "\n"; + if (list.index) { + html += "\n"; + } + + if (list.multiSelect) { + html += buildSelectAll().prop('outerHTML'); + } else if (options.mode === 'lookup') { + html += ""; + } + + if (options.mode !== 'lookup'){ + for (fld in list.fields) { + let customClass = list.fields[fld].columnClass || ''; + html += ``; + } + } + if (options.mode === 'lookup') { + let customClass = list.fields.name.modalColumnClass || ''; + html += ``; + + } + if (options.mode === 'select') { + html += ""; + } else if (options.mode === 'edit' && list.fieldActions) { + html += "\n"; + } + html += "\n"; + html += "\n"; + return html; + }, + + wrapPanel: function(html){ + return`
    ${html}
    `; + } + }; + } +]; diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.block.less b/awx/ui/client/src/shared/lookup/lookup-modal.block.less new file mode 100644 index 0000000000..a0f331ca0f --- /dev/null +++ b/awx/ui/client/src/shared/lookup/lookup-modal.block.less @@ -0,0 +1,6 @@ +.Lookup .modal-body{ + padding-top: 0px; +} +.Lookup-cancel{ + margin-right: 20px; +} diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.directive.js b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js new file mode 100644 index 0000000000..5950363d3c --- /dev/null +++ b/awx/ui/client/src/shared/lookup/lookup-modal.directive.js @@ -0,0 +1,34 @@ +export default ['templateUrl', '$compile', function(templateUrl, $compile) { + return { + restrict: 'E', + replace: true, + transclude: true, + scope: false, + templateUrl: templateUrl('shared/lookup/lookup-modal'), + link: function(scope, element, attrs, controller, transcludefn) { + + transcludefn(scope, (clone, linked_scope) => { + // scope.$resolve is a reference to resolvables in stateDefinition.resolve block + // https://ui-router.github.io/docs/latest/interfaces/state.statedeclaration.html#resolve + let list = linked_scope.$resolve.ListDefinition, + Dataset = linked_scope.$resolve.Dataset; + // search init + linked_scope.list = list; + linked_scope[`${list.iterator}_dataset`] = Dataset.data; + linked_scope[list.name] = linked_scope[`${list.iterator}_dataset`].results; + + element.find('.modal-body').append(clone); + + }); + $('#form-modal').modal('show'); + }, + controller: ['$scope', '$state', function($scope, $state) { + $scope.saveForm = function() { + let list = $scope.list; + $scope.$parent[`${list.iterator}_name`] = $scope.selection[list.iterator].name; + $scope.$parent[list.iterator] = $scope.selection[list.iterator].id; + $state.go('^'); + }; + }] + }; +}]; diff --git a/awx/ui/client/src/shared/lookup/lookup-modal.partial.html b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html new file mode 100644 index 0000000000..7840d25217 --- /dev/null +++ b/awx/ui/client/src/shared/lookup/lookup-modal.partial.html @@ -0,0 +1,24 @@ + diff --git a/awx/ui/client/src/shared/lookup/main.js b/awx/ui/client/src/shared/lookup/main.js new file mode 100644 index 0000000000..df069649ab --- /dev/null +++ b/awx/ui/client/src/shared/lookup/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import directive from './lookup-modal.directive'; + +export default + angular.module('LookupModalModule', []) + .directive('lookupModal', directive); diff --git a/awx/ui/client/src/shared/main.js b/awx/ui/client/src/shared/main.js index 8f92fab287..78bc5d19f9 100644 --- a/awx/ui/client/src/shared/main.js +++ b/awx/ui/client/src/shared/main.js @@ -5,22 +5,40 @@ *************************************************/ import listGenerator from './list-generator/main'; -import pagination from './pagination/main'; +import formGenerator from './form-generator'; +import lookupModal from './lookup/main'; +import smartSearch from './smart-search/main'; +import paginate from './paginate/main'; +import columnSort from './column-sort/main'; +import title from './title.directive'; import lodashAsPromised from './lodash-as-promised'; import stringFilters from './string-filters/main'; import truncatedText from './truncated-text.directive'; import stateExtender from './stateExtender.provider'; import rbacUiControl from './rbacUiControl'; import socket from './socket/main'; +import templateUrl from './template-url/main'; +import RestServices from '../rest/main'; +import stateDefinitions from './stateDefinitions.factory'; +import apiLoader from './api-loader'; export default angular.module('shared', [listGenerator.name, - pagination.name, + formGenerator.name, + lookupModal.name, + smartSearch.name, + paginate.name, + columnSort.name, stringFilters.name, 'ui.router', rbacUiControl.name, - socket.name + socket.name, + templateUrl.name, + RestServices.name, + apiLoader.name, + require('angular-cookies'), ]) + .factory('stateDefinitions', stateDefinitions) .factory('lodashAsPromised', lodashAsPromised) .directive('truncatedText', truncatedText) .provider('$stateExtender', stateExtender); diff --git a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js index 4867c5e07c..068abb1fa7 100644 --- a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js @@ -30,7 +30,7 @@ export default item: '=item' }, require: '^multiSelectList', - template: '', + template: '', link: function(scope, element, attrs, multiSelectList) { scope.decoratedItem = multiSelectList.registerItem(scope.item); diff --git a/awx/ui/client/src/shared/paginate/main.js b/awx/ui/client/src/shared/paginate/main.js new file mode 100644 index 0000000000..394f9b9034 --- /dev/null +++ b/awx/ui/client/src/shared/paginate/main.js @@ -0,0 +1,13 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import directive from './paginate.directive'; +import controller from './paginate.controller'; + +export default + angular.module('PaginateModule', []) + .directive('paginate', directive) + .controller('PaginateController', controller); diff --git a/awx/ui/client/src/shared/paginate/paginate.block.less b/awx/ui/client/src/shared/paginate/paginate.block.less new file mode 100644 index 0000000000..c7836dae3d --- /dev/null +++ b/awx/ui/client/src/shared/paginate/paginate.block.less @@ -0,0 +1,53 @@ + @import "./client/src/shared/branding/colors.default.less"; + @import "./client/src/shared/branding/colors.less"; + // @todo cleanup these messy overrides for styles in ansible-ui.min.css + + +.Paginate-controls--first a, +.Paginate-controls--previous a{ + border-radius: 4px 0 0 4px; + } +.Paginate-controls--last a, +.Paginate-controls--next a{ + border-radius: 0px 4px 4px 0; +} + + .Paginate-controls--item a { + font-size: 12px; + padding: 3px 6px !important; + border-color: @list-pagin-bord; + } + + .Paginate { + margin-top: 20px; + font-size: 12px !important; + color: @list-pagin-text; + text-transform: uppercase; + height: 22px; + display: flex; + justify-content: flex-end; + } + + .Paginate-pager--pageof { + line-height: 22px; + margin-left: 10px; + } + + .Paginate-wrapper { + display: flex; + flex: 1 0 auto; + } + + .Paginate-controls { + margin-top: 0; + margin-bottom: 7px; + display: inline-block; + padding-left: 0; + border-radius: 4px; + } + + .Paginate-controls--active { + color: #fff !important; + border-color: @default-icon-hov !important; + background-color: @default-icon-hov !important; + } diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js new file mode 100644 index 0000000000..b8d0a4ecf5 --- /dev/null +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -0,0 +1,66 @@ +export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'QuerySet', + function($scope, $stateParams, $state, $filter, GetBasePath, qs) { + + let pageSize = $stateParams[`${$scope.iterator}_search`].page_size || 20, + queryset, path; + $scope.pageSize = pageSize; + + function init() { + $scope.pageRange = calcPageRange($scope.current(), $scope.last()); + $scope.dataRange = calcDataRange(); + } + $scope.dataCount = function() { + return $filter('number')($scope.dataset.count); + }; + + $scope.toPage = function(page) { + path = GetBasePath($scope.basePath) || $scope.basePath; + queryset = _.merge($stateParams[`${$scope.iterator}_search`], { page: page }); + $state.go('.', { + [$scope.iterator + '_search']: queryset + }); + qs.search(path, queryset).then((res) => { + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + $scope.pageRange = calcPageRange($scope.current(), $scope.last()); + $scope.dataRange = calcDataRange(); + }; + + $scope.current = function() { + return parseInt($stateParams[`${$scope.iterator}_search`].page || '1'); + }; + + $scope.last = function() { + return Math.ceil($scope.dataset.count / pageSize); + }; + + function calcPageRange(current, last) { + let result = []; + if (last < 10) { + result = _.range(1, last + 1); + } else if (current - 5 > 0 && current !== last) { + result = _.range(current - 5, current + 6); + } else if (current === last) { + result = _.range(last - 10, last + 1); + } else { + result = _.range(1, 11); + } + return result; + } + + function calcDataRange() { + if ($scope.current() == 1 && $scope.dataset.count < parseInt(pageSize)) { + return `1 - ${$scope.dataset.count}`; + } else if ($scope.current() == 1) { + return `1 - ${pageSize}`; + } else { + let floor = (($scope.current() - 1) * parseInt(pageSize)) + 1; + let ceil = floor + parseInt(pageSize); + return `${floor} - ${ceil}`; + } + } + + init(); + } +]; diff --git a/awx/ui/client/src/shared/paginate/paginate.directive.js b/awx/ui/client/src/shared/paginate/paginate.directive.js new file mode 100644 index 0000000000..e839c5b6b0 --- /dev/null +++ b/awx/ui/client/src/shared/paginate/paginate.directive.js @@ -0,0 +1,16 @@ +export default ['templateUrl', + function(templateUrl) { + return { + restrict: 'E', + replace: false, + scope: { + collection: '=', + dataset: '=', + iterator: '@', + basePath: '@' + }, + controller: 'PaginateController', + templateUrl: templateUrl('shared/paginate/paginate') + }; + } +]; diff --git a/awx/ui/client/src/shared/paginate/paginate.partial.html b/awx/ui/client/src/shared/paginate/paginate.partial.html new file mode 100644 index 0000000000..ac1eff1d35 --- /dev/null +++ b/awx/ui/client/src/shared/paginate/paginate.partial.html @@ -0,0 +1,44 @@ +
    +
    + + Page + {{current()}} of + {{last()}} + +
    +
    + ITEMS  + {{dataRange}} + of {{dataCount()}} + +
    +
    diff --git a/awx/ui/client/src/shared/pagination/main.js b/awx/ui/client/src/shared/pagination/main.js deleted file mode 100644 index 088bb0e399..0000000000 --- a/awx/ui/client/src/shared/pagination/main.js +++ /dev/null @@ -1,11 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import pagination from './pagination.service'; - -export default - angular.module('pagination', []) - .factory('pagination', pagination); diff --git a/awx/ui/client/src/shared/pagination/pagination.service.js b/awx/ui/client/src/shared/pagination/pagination.service.js deleted file mode 100644 index 73249da8ad..0000000000 --- a/awx/ui/client/src/shared/pagination/pagination.service.js +++ /dev/null @@ -1,44 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -export default ['$http', '$q', function($http, $q) { - return { - getInitialPageForList: function(id, url, pageSize) { - // get the name of the object - if ($.isNumeric(id)) { - return $http.get(url + "?id=" + id) - .then(function (data) { - var queryValue, queryType; - if (data.data.results.length) { - if (data.data.results[0].type === "user") { - queryValue = data.data.results[0].username; - queryType = "username"; - } else { - queryValue = data.data.results[0].name; - queryType = "name"; - } - } else { - queryValue = ""; - queryType = "name"; - } - // get how many results are less than or equal to - // the name - return $http.get(url + "?" + queryType + "__lte=" + queryValue) - .then(function (data) { - // divide by the page size to get what - // page the data should be on - var count = data.data.count; - return Math.max(1, Math.ceil(count/parseInt(pageSize))); - }); - }); - } else { - var defer = $q.defer(); - defer.resolve(1); - return(defer.promise); - } - } - }; -}]; diff --git a/awx/ui/client/src/shared/smart-search/django-search-model.class.js b/awx/ui/client/src/shared/smart-search/django-search-model.class.js new file mode 100644 index 0000000000..5271a38a30 --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/django-search-model.class.js @@ -0,0 +1,56 @@ +// Ignored fields are not surfaced in the UI's search key +let isIgnored = function(key, value) { + let ignored = [ + 'type', + 'url', + 'related', + 'summary_fields', + 'object_roles', + 'activity_stream', + 'update', + 'teams', + 'users', + 'owner_teams', + 'owner_users', + 'access_list', + 'notification_templates_error', + 'notification_templates_success', + 'ad_hoc_command_events', + 'fact_versions', + 'variable_data', + 'playbooks' + ]; + return ignored.indexOf(key) > -1 || value.type === 'field'; +}; + +export default +class DjangoSearchModel { + /* + @property name - supplied model name + @property base { + field: { + type: 'string' // string, bool, field, choice, datetime, + label: 'Label', // Capitalized + help_text: 'Some helpful descriptive text' + } + } + @@property related ['field' ...] + */ + constructor(name, endpoint, baseFields, relations) { + let base = {}; + this.name = name; + this.related = _.reject(relations, isIgnored); + _.forEach(baseFields, (value, key) => { + if (!isIgnored(key, value)) { + base[key] = value; + } + }); + this.base = base; + } + + fields() { + let result = this.base; + result.related = this.related; + return result; + } +} diff --git a/awx/ui/client/src/shared/smart-search/main.js b/awx/ui/client/src/shared/smart-search/main.js new file mode 100644 index 0000000000..7653df7bd7 --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/main.js @@ -0,0 +1,12 @@ +import directive from './smart-search.directive'; +import controller from './smart-search.controller'; +import service from './queryset.service'; +import DjangoSearchModel from './django-search-model.class'; + + +export default +angular.module('SmartSearchModule', []) + .directive('smartSearch', directive) + .controller('SmartSearchController', controller) + .service('QuerySet', service) + .constant('DjangoSearchModel', DjangoSearchModel); diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js new file mode 100644 index 0000000000..12057c1a90 --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -0,0 +1,116 @@ +export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', '$cacheFactory', 'GetBasePath', + function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, $cacheFactory, GetBasePath) { + return { + // kick off building a model for a specific endpoint + // this is usually a list's basePath + // unified_jobs is the exception, where we need to fetch many subclass OPTIONS and summary_fields + initFieldset(path, name, relations) { + // get or set $cachFactory.Cache object with id '$http' + let defer = $q.defer(), + cache = $cacheFactory.get('$http') || $cacheFactory('$http'); + defer.resolve(this.getCommonModelOptions(path, name, relations, cache)); + return defer.promise; + }, + + getCommonModelOptions(path, name, relations, cache) { + let resolve, base, + defer = $q.defer(); + + // grab a single model from the cache, if present + if (cache.get(path)) { + defer.resolve({[name] : new DjangoSearchModel(name, path, cache.get(path), relations)}); + } else { + this.url = path; + resolve = this.options(path) + .then((res) => { + base = res.data.actions.GET; + defer.resolve({[name]: new DjangoSearchModel(name, path, base, relations)}); + }); + } + return defer.promise; + }, + + /* @extendme + // example: + // retrieving options from a polymorphic model (unified_job) + getPolymorphicModelOptions(path, name) { + let defer = $q.defer(), + paths = { + project_update: GetBasePath('project_update'), + inventory_update: GetBasePath('inventory_update'), + job: GetBasePath('jobs'), + ad_hoc_command: GetBasePath('ad_hoc_commands'), + system_job: GetBasePath('system_jobs') + }; + defer.all( // for each getCommonModelOptions() ); + return defer.promise; + }, + */ + + // encodes ui-router params from {operand__key__comparator: value} pairs to API-consumable URL + encodeQueryset(params) { + let queryset; + queryset = _.reduce(params, (result, value, key) => { + return result + `${key}=${value}&`; + }, ''); + queryset = queryset.substring(0, queryset.length - 1); + return angular.isObject(params) ? `?${queryset}` : ''; + }, + // encodes a ui smart-search param to a django-friendly param + // operand:key:comparator:value => {operand__key__comparator: value} + encodeParam(param){ + let split = param.split(':'); + return {[split.slice(0,split.length -1).join('__')] : split[split.length-1]}; + }, + // decodes a django queryset param into ui smart-search param + decodeParam(key, value){ + return `${key.split('__').join(':')}:${value}`; + }, + + // encodes a django queryset for ui-router's URLMatcherFactory + // {operand__key__comparator: value, } => 'operand:key:comparator:value,...' + encodeArr(params) { + let url; + url = _.reduce(params, (result, value, key) => { + return result.concat(`${key}:${value}`); + }, []); + return url.join(';'); + }, + + // decodes a django queryset for ui-router's URLMatcherFactory + // 'operand:key:comparator:value,...' => {operand__key__comparator: value, } + decodeArr(arr) { + let params = {}; + _.forEach(arr.split(';'), (item) => { + let key = item.split(':')[0], + value = item.split(':')[1]; + params[key] = value; + }); + return params; + }, + // REST utilities + options(endpoint) { + Rest.setUrl(endpoint); + return Rest.options(endpoint); + }, + search(endpoint, params) { + Wait('start'); + this.url = `${endpoint}${this.encodeQueryset(params)}`; + Rest.setUrl(this.url); + return Rest.get() + .success(this.success.bind(this)) + .error(this.error.bind(this)) + .finally(Wait('stop')); + }, + error(data, status) { + ProcessErrors($rootScope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + this.url + '. GET returned: ' + status + }); + }, + success(data) { + return data; + }, + }; + } +]; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.block.less b/awx/ui/client/src/shared/smart-search/smart-search.block.less new file mode 100644 index 0000000000..23afbc7791 --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/smart-search.block.less @@ -0,0 +1,234 @@ +@import "../branding/colors.default.less"; +.SmartSearch { + margin-bottom: 10px; + min-height: 45px; + padding-left: 15px; + padding-right: 15px; +} + +.SmartSearch-form { + width: 100%; +} + +.SmartSearch-bar { + display: flex; + padding: 0; + font-size: 12px; + height: 35px; + align-items: stretch; + margin-bottom: 10px; + line-height: 20px; +} +// `.${list.name}List` class can be used to set add custom class overrides +.groupsList .SmartSearch-bar, .hostsList .SmartSearch-bar, .PortalMode .SmartSearch-bar{ + width: 100%; +} + +.SmartSearch-tags{ + padding-left: 0px; +} + +.SmartSearch-bar i { + font-size: 16px; + color: @default-icon; +} + +.SmartSearch-searchTermContainer { + flex: initial; + width: ~"calc(100% - 100px)"; + border: 1px solid @d7grey; + border-radius: 4px; + display: flex; + background-color: @default-bg; + position: relative; +} + +.SmartSearch-searchTermContainer.is-open { + border-bottom-right-radius: 0; +} + +.SmartSearch-input { + flex: 1 0 auto; + margin: 0 10px; + border: none; + font-size: 14px; + height: 100%; + width: 100%; +} + +.SmartSearch-input:focus, +.SmartSearch-input:active { + outline: 0; +} + +.SmartSearch-searchTermContainer input:placeholder-shown { + color: @default-icon !important; + text-transform: uppercase; +} + +.SmartSearch-searchButton { + flex: initial; + margin-left: auto; + padding: 8px 10px; + border-left: 1px solid @d7grey; + background-color: @default-bg; + cursor: pointer; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +} + +.SmartSearch-searchButton:hover { + background-color: @default-tertiary-bg; +} + +.SmartSearch-flexContainer { + display: flex; + width: 100%; + flex-wrap: wrap; +} + +.SmartSearch-tagContainer { + display: flex; + max-width: 100%; + margin-bottom: 10px; +} + +.SmartSearch-tag { + border-radius: 5px; + padding: 2px 10px; + margin: 4px 0px; + font-size: 12px; + color: @default-interface-txt; + background-color: @default-bg; + margin-right: 5px; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.SmartSearch-tag--deletable { + margin-right: 0px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-right: 0; + max-width: ~"calc(100% - 23px)"; + background-color: @default-link; + color: @default-bg; + margin-right: 5px; +} + +.SmartSearch-deleteContainer { + background-color: @default-link!important; + color: white; + background-color: @default-bg; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + padding: 0 5px; + margin: 4px 0px; + align-items: center; + display: flex; + cursor: pointer; +} + +.SmartSearch-tagDelete { + font-size: 13px; +} + +.SmartSearch-name { + flex: initial; + max-width: 100%; +} + +.SmartSearch-tag--deletable > .SmartSearch-name { + max-width: ~"calc(100% - 23px)"; +} + +.SmartSearch-deleteContainer:hover, +{ + border-color: @default-err; + background-color: @default-err!important; +} + +.SmartSearch-deleteContainer:hover > .SmartSearch-tagDelete { + color: @default-bg; +} +.SmartSearch-clearAll{ + font-size: 12px; + padding-top: 5px; +} +.SmartSearch-keyToggle { + margin-left: auto; + text-transform: uppercase; + background-color: @default-bg; + border-radius: 5px; + color: @default-interface-txt; + border: 1px solid @d7grey; + cursor: pointer; + width: 70px; + height: 34px; + line-height: 20px; +} + +.SmartSearch-keyToggle:hover { + background-color: @default-tertiary-bg; +} + +.SmartSearch-keyToggle.is-active { + background-color: @default-link; + border-color: @default-link; + color: @default-bg; + &:hover{ + background-color: @default-link-hov; + } +} + +.SmartSearch-keyPane { + max-height: 200px; + overflow: auto; + display: flex; + flex-wrap: wrap; + margin: 0px 0px 20px 0px; + font-size: 12px; + width: 100%; + padding: 15px; + margin-bottom: 15px; + border-radius: 4px; + border: 1px solid @login-notice-border; + background-color: @login-notice-bg; + color: @login-notice-text; +} + +.SmartSearch-relations{ + margin-top: 15px; +} + +.SmartSearch-keyRow { + width: 33%; + flex: 1 1 auto; + flex-direction: column; + margin-bottom: 15px; + padding-right: 50px; +} +// 100% rows in a modal +.modal-body .SmartSearch-keyRow{ + width: 100%; +} +// `.${list.name}List` class can be used to set add custom class overrides +.groupsList .SmartSearch-keyRow, .hostsList .SmartSearch-keyRow, .PortalMode .SmartSearch-keyRow{ + width: 100%; +} +.SmartSearch-keyRow:nth-child(3){ + padding-right: 0px; +} + +.SmartSearch-keyName { + flex: 1 0 auto; + text-transform: uppercase; + font-weight: bold; + padding-bottom: 3px; +} + +.SmartSearch-keyComparators { + flex: 1 0 auto; +} diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js new file mode 100644 index 0000000000..7de3f82886 --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -0,0 +1,113 @@ +export default ['$stateParams', '$scope', '$state', 'QuerySet', 'GetBasePath', 'QuerySet', + function($stateParams, $scope, $state, QuerySet, GetBasePath, qs) { + + let path, relations, + // steps through the current tree of $state configurations, grabs default search params + defaults = _.find($state.$current.path, (step) => { + return step.params.hasOwnProperty(`${$scope.iterator}_search`); + }).params[`${$scope.iterator}_search`].config.value, + queryset = $stateParams[`${$scope.iterator}_search`]; + + // build $scope.tags from $stateParams.QuerySet, build fieldset key + init(); + + function init() { + path = GetBasePath($scope.basePath) || $scope.basePath; + relations = getRelationshipFields($scope.dataset.results); + $scope.searchTags = stripDefaultParams($state.params[`${$scope.iterator}_search`]); + qs.initFieldset(path, $scope.djangoModel, relations).then((models) => { + $scope.models = models; + }); + } + + // Removes state definition defaults and pagination terms + function stripDefaultParams(params) { + return _.pick(params, (value, key) => { + // setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value + return defaults[key] !== value && key !== 'page' && key !== 'page_size' && defaults[key] !== null; + }); + } + + // searchable relationships + function getRelationshipFields(dataset) { + let flat = _(dataset).map((value) => { + return _.keys(value.related); + }).flatten().uniq().value(); + return flat; + } + + $scope.toggleKeyPane = function() { + $scope.showKeyPane = !$scope.showKeyPane; + }; + + $scope.clearAll = function(){ + let cleared = defaults; + delete cleared.page; + queryset = cleared; + $state.go('.', {[$scope.iterator + '_search']: queryset}); + qs.search(path, queryset).then((res) => { + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + $scope.searchTags = stripDefaultParams(queryset); + }; + + // remove tag, merge new queryset, $state.go + $scope.remove = function(key) { + delete queryset[key]; + $state.go('.', { + [$scope.iterator + '_search']: queryset }); + qs.search(path, queryset).then((res) => { + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + $scope.searchTags = stripDefaultParams(queryset); + }; + + // add a search tag, merge new queryset, $state.go() + $scope.add = function(terms) { + let params = {}; + + _.forEach(terms.split(' '), (term) => { + // if only a value is provided, search using default keys + if (term.split(':').length === 1) { + params = _.merge(params, setDefaults(term)); + } else { + params = _.merge(params, qs.encodeParam(term)); + } + }); + + function setDefaults(term) { + // "name" and "description" are sane defaults for MOST models, but not ALL! + // defaults may be configured in ListDefinition.defaultSearchParams + if ($scope.list.defaultSearchParams) { + return $scope.list.defaultSearchParams(term); + } else { + return { + or__name__icontains: term, + or__description__icontains: term + }; + } + } + + params.page = '1'; + queryset = _.merge(queryset, params); + // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic + // This transition will not reload controllers/resolves/views + // but will register new $stateParams[$scope.iterator + '_search'] terms + $state.go('.', { + [$scope.iterator + '_search']: queryset }); + qs.search(path, queryset).then((res) => { + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + + $scope.searchTerm = null; + $scope.searchTags = stripDefaultParams(queryset); + }; + + $scope.decodeParam = function(key, value) { + return qs.decodeParam(key, value); + }; + } +]; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.directive.js b/awx/ui/client/src/shared/smart-search/smart-search.directive.js new file mode 100644 index 0000000000..4cf67ed51f --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/smart-search.directive.js @@ -0,0 +1,23 @@ +export default ['templateUrl', + function(templateUrl) { + return { + restrict: 'E', + replace: false, + transclude: { + actions: '?div' // preferably would transclude an actions directive here + }, + scope: { + djangoModel: '@', + searchSize: '@', + basePath: '@', + iterator: '@', + list: '=', + dataset: '=', + collection: '=', + searchTags: '=', + }, + controller: 'SmartSearchController', + templateUrl: templateUrl('shared/smart-search/smart-search') + }; + } +]; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.partial.html b/awx/ui/client/src/shared/smart-search/smart-search.partial.html new file mode 100644 index 0000000000..7da08310eb --- /dev/null +++ b/awx/ui/client/src/shared/smart-search/smart-search.partial.html @@ -0,0 +1,57 @@ +
    + +
    +
    + +
    + + +
    + +
    +
    +
    + Key +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    + {{decodeParam(key, value)}} +
    +
    + CLEAR ALL +
    +
    +
    + +
    +
    + +
    +
    +
    + {{ key }} +
    +
    +
    Type: {{ value.type }}
    +
    Description: {{value.help_text}}
    +
    + Enumerated: {{ choice[0] }} +
    +
    +
    +
    + Searchable relationships: {{ relation }}, +
    +
    +
    diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index 5cd9ebfe40..af76fc736b 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -30,8 +30,7 @@ export default self.socket = new ReconnectingWebSocket(url, null, { timeoutInterval: 3000, - maxReconnectAttempts: 10 - }); + maxReconnectAttempts: 10 }); self.socket.onopen = function () { $log.debug("Websocket connection opened."); @@ -127,7 +126,7 @@ export default // listen for specific messages. A subscription object could // look like {"groups":{"jobs": ["status_changed", "summary"]}. // This is used by all socket-enabled $states - this.emit(JSON.stringify(state.socket)); + this.emit(JSON.stringify(state.data.socket)); this.setLast(state); }, unsubscribe: function(state){ @@ -136,7 +135,7 @@ export default // to the API: {"groups": {}}. // This is used for all pages that are socket-disabled if(this.requiresNewSubscribe(state)){ - this.emit(JSON.stringify(state.socket)); + this.emit(JSON.stringify(state.data.socket)); } this.setLast(state); }, @@ -151,7 +150,7 @@ export default // required an "unsubscribe", then we don't need to unsubscribe // again, b/c the UI is already unsubscribed from all groups if (this.getLast() !== undefined){ - if( _.isEmpty(state.socket.groups) && _.isEmpty(this.getLast().socket.groups)){ + if( _.isEmpty(state.data.socket.groups) && _.isEmpty(this.getLast().data.socket.groups)){ return false; } else { @@ -206,16 +205,16 @@ export default // requires a subscribe or an unsubscribe var self = this; socketPromise.promise.then(function(){ - if(!state.socket){ - state.socket = {groups: {}}; + if(!state.data && !state.data.socket){ + state.data.socket = {groups: {}}; self.unsubscribe(state); } else{ - if(state.socket.groups.hasOwnProperty( "job_events")){ - state.socket.groups.job_events = [id]; + if(state.data && state.data.socket && state.data.socket.groups.hasOwnProperty( "job_events")){ + state.data.socket.groups.job_events = [id]; } - if(state.socket.groups.hasOwnProperty( "ad_hoc_command_events")){ - state.socket.groups.ad_hoc_command_events = [id]; + if(state.data && state.data.socket && state.data.socket.groups.hasOwnProperty( "ad_hoc_command_events")){ + state.data.socket.groups.ad_hoc_command_events = [id]; } self.subscribe(state); } diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js new file mode 100644 index 0000000000..b65ecfebf5 --- /dev/null +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -0,0 +1,434 @@ +/** + * @ngdoc interface + * @name stateDefinitions + * @description An API for generating a standard set of state definitions + * generateTree - builds a full list/form tree + * generateListNode - builds a single list node e.g. {name: 'projects', ...} + * generateFormNode - builds a form node definition e.g. {name: 'projects.add', ...} + * generateFormListDefinitions - builds form list definitions attached to a form node e.g. {name: 'projects.edit.permissions', ...} + * generateLookupNodes - Attaches to a form node. Builds an abstract '*.lookup' node with field-specific 'lookup.*' children e.g. {name: 'projects.add.lookup.organizations', ...} + */ + +import { templateUrl } from './template-url/template-url.factory'; + +export default ['$injector', '$stateExtender', '$log', function($injector, $stateExtender, $log) { + return { + /** + * @ngdoc method + * @name stateDefinitions.generateTree + * @description intended for consumption by $stateProvider.state.lazyLoad in a placeholder node + * @param {object} params + { + parent: 'stateName', // the name of the top-most node of this tree + modes: ['add', 'edit'], // form modes to include in this state tree + list: 'InjectableListDefinition', + form: 'InjectableFormDefinition', + controllers: { + list: 'Injectable' || Object, + add: 'Injectable' || Object, + edit: 'Injectable' || Object, + } + * @returns {object} Promise which resolves to an object.state containing array of all state definitions in this tree + * e.g. {state: [{...}, {...}, ...]} + */ + generateTree: function(params) { + let form, list, formStates, listState, + states = []; + //return defer.promise; + return new Promise((resolve) => { + // returns array of the following states: + // resource.add, resource.edit + // resource.add.lookup, resource.add.lookup.* => [field in form.fields if field.type == 'lookup'] + // resource.edit.lookup, resource.edit.lookup.* => [field in form.fields if field.type == 'lookup'] + // resource.edit.* => [relationship in form.related] + if (params.list) { + list = $injector.get(params.list); + + listState = this.generateListNode(list, params); + states.push(listState); + } + if (params.form) { + // handle inconsistent typing of form definitions + // can be either an object or fn + form = $injector.get(params.form); + form = typeof(form) === 'function' ? form() : form; + + formStates = _.map(params.modes, (mode) => this.generateFormNode(mode, form, params)); + states = states.concat(_.flatten(formStates)); + $log.debug('*** Generated State Tree', states); + resolve({ states: states }); + } + }); + }, + + /** + * @ngdoc method + * @name stateDefinitions.generateListNode + * @description builds single list node + * @params {object} list - list definition/configuration object + * @params {object} params + * @returns {object} a list state definition + */ + generateListNode: function(list, params) { + let state; + + // allows passed-in params to specify a custom templateUrl + // otherwise, use html returned by generateList.build() to fulfill templateProvider fn + function generateTemplateBlock() { + if (params.templates && params.templates.list) { + return params.templates.list; + } else { + return function(ListDefinition, generateList) { + let html = generateList.build({ + list: ListDefinition, + mode: 'edit' + }); + html = generateList.wrapPanel(html); + return html; + }; + } + } + state = $stateExtender.buildDefinition({ + searchPrefix: list.iterator, + name: params.parent, + url: (params.url || `/${list.name}`), + data: params.data, + ncyBreadcrumb: { + label: list.title + }, + resolve: { + Dataset: [params.list, 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ], + ListDefinition: () => list + }, + views: { + 'list@': { + // resolves to a variable property name: + // 'templateUrl' OR 'templateProvider' + [params.templates && params.templates.list ? 'templateUrl' : 'templateProvider']: generateTemplateBlock(), + controller: params.controllers.list, + } + } + }); + // allow passed-in params to override default resolve block + if (params.resolve && params.resolve.list) { + state.resolve = _.merge(state.resolve, params.resolve.list); + } + // allow passed-in params to override default ncyBreadcrumb property + if (params.ncyBreadcrumb) { + state.ncyBreadcrumb = params.ncyBreadcrumb; + } + if (list.search) { + state.params[`${list.iterator}_search`].value = _.merge(state.params[`${list.iterator}_search`].value, list.search); + } + return state; + }, + /** + * @ngdoc method + * @name stateDefinitions.generateFormNode + * @description builds a node of form states, e.g. resource.edit.** or resource.add.** + * @param {string} mode - 'add' || 'edit' - the form mode of this node + * @param {object} form - form definition/configuration object + * @returns {array} Array of state definitions required by form mode [{...}, {...}, ...] + */ + generateFormNode: function(mode, form, params) { + let formNode, states = []; + switch (mode) { + case 'add': + formNode = $stateExtender.buildDefinition({ + name: params.name || `${params.parent}.add`, + url: params.url || '/add', + ncyBreadcrumb: { + [params.parent ? 'parent' : null]: `${params.parent}`, + label: `CREATE ${form.name}` + }, + views: { + 'form@': { + templateProvider: function(FormDefinition, GenerateForm) { + let form = typeof(FormDefinition) === 'function' ? + FormDefinition() : FormDefinition; + return GenerateForm.buildHTML(form, { + mode: 'add', + related: false + }); + }, + controller: params.controllers.add + } + }, + resolve: { + 'FormDefinition': [params.form, function(definition) { + return definition; + }] + } + }); + if (params.resolve && params.resolve.add) { + formNode.resolve = _.merge(formNode.resolve, params.resolve.add); + } + break; + case 'edit': + formNode = $stateExtender.buildDefinition({ + name: params.name || `${params.parent}.edit`, + url: (params.url || `/:${form.name}_id`), + ncyBreadcrumb: { + [params.parent ? 'parent' : null]: `${params.parent}`, + label: '{{parentObject.name || name}}' + }, + views: { + 'form@': { + templateProvider: function(FormDefinition, GenerateForm) { + let form = typeof(FormDefinition) === 'function' ? + FormDefinition() : FormDefinition; + return GenerateForm.buildHTML(form, { + mode: 'edit' + }); + }, + controller: params.controllers.edit + } + }, + resolve: { + FormDefinition: [params.form, function(definition) { + return definition; + }], + resourceData: ['FormDefinition', 'Rest', '$stateParams', 'GetBasePath', + function(FormDefinition, Rest, $stateParams, GetBasePath) { + let form, path; + form = typeof(FormDefinition) === 'function' ? + FormDefinition() : FormDefinition; + if (GetBasePath(form.basePath) === undefined && GetBasePath(form.stateTree) === undefined ){ + throw { name: 'NotImplementedError', message: `${form.name} form definition is missing basePath or stateTree property.` }; + } + else{ + path = (GetBasePath(form.basePath) || GetBasePath(form.stateTree) || form.basePath) + $stateParams[`${form.name}_id`]; + } + Rest.setUrl(path); + return Rest.get(); + } + ] + } + }); + if (params.resolve && params.resolve.edit) { + formNode.resolve = _.merge(formNode.resolve, params.resolve.edit); + } + break; + } + states.push(formNode); + states = states.concat(this.generateLookupNodes(form, formNode)).concat(this.generateFormListDefinitions(form, formNode)); + return states; + }, + /** + * @ngdoc method + * @name stateDefinitions.generateFormListDefinitions + * @description builds state definitions for a form's related lists, like notifications/permissions + * @param {object} form - form definition/configuration object + * @params {object} formStateDefinition - the parent form node + * @returns {array} Array of state definitions [{...}, {...}, ...] + */ + generateFormListDefinitions: function(form, formStateDefinition) { + + function buildPermissionDirective() { + let states = []; + + states.push($stateExtender.buildDefinition({ + name: `${formStateDefinition.name}.permissions.add`, + squashSearchUrl: true, + url: '/add-permissions', + params: { + user_search: { + value: { order_by: 'username', page_size: '5' }, + dynamic: true, + }, + team_search: { + value: { order_by: 'name', page_size: '5' }, + dynamic: true + } + }, + views: { + [`modal@${formStateDefinition.name}`]: { + template: `` + } + }, + resolve: { + usersDataset: ['addPermissionsUsersList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams.user_search); + + } + ], + teamsDataset: ['addPermissionsTeamsList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams.team_search); + } + ] + }, + onExit: function($state) { + if ($state.transition) { + $('#add-permissions-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + })); + return states; + } + + function buildListNodes(field) { + let states = []; + states.push(buildListDefinition(field)); + if (field.iterator === 'permission' && field.actions && field.actions.add) { + states.push(buildPermissionDirective()); + states = _.flatten(states); + } + return states; + } + + function buildListDefinition(field) { + let state, + list = field.include ? $injector.get(field.include) : field; + state = $stateExtender.buildDefinition({ + searchPrefix: `${list.iterator}`, + name: `${formStateDefinition.name}.${list.iterator}s`, + url: `/${list.iterator}s`, + ncyBreadcrumb: { + parent: `${formStateDefinition.name}`, + label: `${field.iterator}s` + }, + params: { + [list.iterator + '_search']: { + value: { order_by: field.order_by ? field.order_by : 'name' } + }, + }, + views: { + 'related': { + templateProvider: function(FormDefinition, GenerateForm) { + let html = GenerateForm.buildCollection({ + mode: 'edit', + related: `${list.iterator}s`, + form: typeof(FormDefinition) === 'function' ? + FormDefinition() : FormDefinition + }); + return html; + }, + controller: ['$scope', 'ListDefinition', 'Dataset', + function($scope, list, Dataset) { + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[`${list.iterator}s`] = $scope[`${list.iterator}_dataset`].results; + } + ] + } + }, + resolve: { + ListDefinition: () => { + return list; + }, + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$interpolate', '$rootScope', + (list, qs, $stateParams, GetBasePath, $interpolate, $rootScope) => { + // allow related list definitions to use interpolated $rootScope / $stateParams in basePath field + let path, interpolator; + if (GetBasePath(list.basePath)) { + path = GetBasePath(list.basePath); + } else { + interpolator = $interpolate(list.basePath); + path = interpolator({ $rootScope: $rootScope, $stateParams: $stateParams }); + } + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ] + } + }); + // appy any default search parameters in form definition + if (field.search) { + state.params[`${field.iterator}_search`].value = _.merge(state.params[`${field.iterator}_search`].value, field.search); + } + return state; + } + return _(form.related).map(buildListNodes).flatten().value(); + }, + /** + * @ngdoc method + * @name stateDefinitions.generateLookupNode + * @description builds a node of child states for each lookup field in a form + * @param {object} form - form definition/configuration object + * @params {object} formStateDefinition - the parent form node + * @returns {array} Array of state definitions [{...}, {...}, ...] + */ + generateLookupNodes: function(form, formStateDefinition) { + + function buildFieldDefinition(field) { + let state = $stateExtender.buildDefinition({ + searchPrefix: field.sourceModel, + squashSearchUrl: true, + name: `${formStateDefinition.name}.${field.sourceModel}`, + url: `/${field.sourceModel}`, + // a lookup field's basePath takes precedence over generic list definition's basePath, if supplied + data: { + basePath: field.basePath || null, + lookup: true + }, + params: { + [field.sourceModel + '_search']: { + value: { page_size: '5' } + } + }, + views: { + 'modal': { + templateProvider: function(ListDefinition, generateList) { + let list_html = generateList.build({ + mode: 'lookup', + list: ListDefinition, + input_type: 'radio' + }); + return `${list_html}`; + + } + } + }, + resolve: { + ListDefinition: [field.list, function(list) { + return list; + }], + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$interpolate', '$rootScope', '$state', + (list, qs, $stateParams, GetBasePath, $interpolate, $rootScope, $state) => { + // allow lookup field definitions to use interpolated $stateParams / $rootScope in basePath field + // the basePath on a form's lookup field will take precedence over the general model list's basepath + let path, interpolator; + if ($state.transition._targetState._definition.data && GetBasePath($state.transition._targetState._definition.data.basePath)) { + path = GetBasePath($state.transition._targetState._definition.data.basePath); + } else if ($state.transition._targetState._definition.data && $state.transition._targetState._definition.data.basePath) { + interpolator = $interpolate($state.transition._targetState._definition.data.basePath); + path = interpolator({ $rootScope: $rootScope, $stateParams: $stateParams }); + } else if (GetBasePath(list.basePath)) { + path = GetBasePath(list.basePath); + } else { + interpolator = $interpolate(list.basePath); + path = interpolator({ $rootScope: $rootScope, $stateParams: $stateParams }); + } + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ] + }, + onExit: function($state) { + if ($state.transition) { + $('#form-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } + }, + }); + if (field.search) { + state.params[`${field.sourceModel}_search`].value = _.merge(state.params[`${field.sourceModel}_search`].value, field.search); + } + return state; + } + return _(form.fields).filter({ type: 'lookup' }).map(buildFieldDefinition).value(); + } + + }; + +}]; diff --git a/awx/ui/client/src/shared/stateExtender.provider.js b/awx/ui/client/src/shared/stateExtender.provider.js index 9c5bf0bfae..da8b34fbee 100644 --- a/awx/ui/client/src/shared/stateExtender.provider.js +++ b/awx/ui/client/src/shared/stateExtender.provider.js @@ -1,10 +1,11 @@ export default function($stateProvider) { this.$get = function() { return { + // attaches socket as resolvable if specified in state definition addSocket: function(state){ // The login route has a 'null' socket because it should // neither subscribe or unsubscribe - if(state.socket!==null){ + if(state.data && state.data.socket!==null){ if(!state.resolve){ state.resolve = {}; } @@ -15,24 +16,58 @@ export default function($stateProvider) { ]; } }, + // builds a state definition with default slaw + buildDefinition: function(state) { + let params, defaults, definition, + searchPrefix = state.searchPrefix ? `${state.searchPrefix}_search` : null, + route = state.route || state.url; - addState: function(state) { - var route = state.route || state.url; - this.addSocket(state); - $stateProvider.state(state.name, { + if (searchPrefix) { + defaults = { + params: { + [searchPrefix]: { + value: { + page_size: "20", + order_by: "name" + }, + dynamic: true, + squash: true + } + } + }; + route = !state.squashSearchUrl ? `${route}?{${searchPrefix}:queryset}` : route; + params = state.params === undefined ? defaults.params : _.merge(defaults.params, state.params); + } else { + params = state.params; + } + + definition = { + name: state.name, url: route, + abstract: state.abstract, controller: state.controller, templateUrl: state.templateUrl, + templateProvider: state.templateProvider, resolve: state.resolve, - params: state.params, + params: params, data: state.data, ncyBreadcrumb: state.ncyBreadcrumb, onEnter: state.onEnter, onExit: state.onExit, template: state.template, controllerAs: state.controllerAs, - views: state.views - }); + views: state.views, + parent: state.parent, + // new in uiRouter 1.0 + lazyLoad: state.lazyLoad, + }; + this.addSocket(definition); + return definition; + }, + // registers a state definition with $stateProvider service + addState: function(state) { + let definition = this.buildDefinition(state); + $stateProvider.state(state.name, definition); } }; }; diff --git a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js index 1b7d546231..e9510deb62 100644 --- a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js +++ b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js @@ -4,24 +4,24 @@ * All Rights Reserved *************************************************/ -import {templateUrl} from '../../shared/template-url/template-url.factory'; +import { templateUrl } from '../../shared/template-url/template-url.factory'; export default { name: 'adHocJobStdout', route: '/ad_hoc_commands/:id', templateUrl: templateUrl('standard-out/adhoc/standard-out-adhoc'), controller: 'JobStdoutController', - socket: { - "groups":{ - "jobs": ["status_changed"], - "ad_hoc_command_events": [] - } - }, ncyBreadcrumb: { parent: "jobs", label: "{{ job.module_name }}" }, data: { - jobType: 'ad_hoc_commands' + jobType: 'ad_hoc_commands', + socket: { + "groups": { + "jobs": ["status_changed"], + "ad_hoc_command_events": [] + } + } } }; diff --git a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js index c93cbd7a92..75472922b8 100644 --- a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js +++ b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js @@ -4,23 +4,23 @@ * All Rights Reserved *************************************************/ -import {templateUrl} from '../../shared/template-url/template-url.factory'; +import { templateUrl } from '../../shared/template-url/template-url.factory'; export default { name: 'managementJobStdout', route: '/management_jobs/:id', templateUrl: templateUrl('standard-out/management-jobs/standard-out-management-jobs'), controller: 'JobStdoutController', - socket: { - "groups":{ - "jobs": ["status_changed"] - } - }, ncyBreadcrumb: { parent: "jobs", label: "{{ job.name }}" }, data: { - jobType: 'system_jobs' + jobType: 'system_jobs', + socket: { + "groups": { + "jobs": ["status_changed"] + } + } } }; diff --git a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js index d027050444..87d0942802 100644 --- a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js +++ b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js @@ -4,7 +4,7 @@ * All Rights Reserved *************************************************/ -import {templateUrl} from '../../shared/template-url/template-url.factory'; +import { templateUrl } from '../../shared/template-url/template-url.factory'; // TODO: figure out what this route should be - should it be scm_update? @@ -17,12 +17,12 @@ export default { parent: "jobs", label: "{{ project_name }}" }, - socket: { - "groups":{ - "jobs": ["status_changed"] - } - }, data: { - jobType: 'project_updates' + jobType: 'project_updates', + socket: { + "groups": { + "jobs": ["status_changed"] + } + }, } }; diff --git a/awx/ui/client/src/widgets/Stream.js b/awx/ui/client/src/widgets/Stream.js index bd9b0011a4..ccd5ad0173 100644 --- a/awx/ui/client/src/widgets/Stream.js +++ b/awx/ui/client/src/widgets/Stream.js @@ -18,8 +18,7 @@ import listGenerator from '../shared/list-generator/main'; -angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefinition', 'SearchHelper', 'PaginationHelpers', - 'RefreshHelper', listGenerator.name, 'StreamWidget', +angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefinition', listGenerator.name, 'StreamWidget', ]) .factory('BuildAnchor', [ '$log', '$filter', @@ -222,12 +221,11 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti .factory('ShowDetail', ['$filter', '$rootScope', 'Rest', 'Alert', 'GenerateForm', 'ProcessErrors', 'GetBasePath', 'FormatDate', 'ActivityDetailForm', 'Empty', 'Find', function ($filter, $rootScope, Rest, Alert, GenerateForm, ProcessErrors, GetBasePath, FormatDate, ActivityDetailForm, Empty, Find) { - return function (params) { + return function (params, scope) { var activity_id = params.activity_id, - parent_scope = params.scope, - activity = Find({ list: parent_scope.activities, key: 'id', val: activity_id }), - scope, element; + activity = Find({ list: params.scope.activities, key: 'id', val: activity_id }), + element; if (activity) { @@ -259,19 +257,16 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti ]) .factory('Stream', ['$rootScope', '$location', '$state', 'Rest', 'GetBasePath', - 'ProcessErrors', 'Wait', 'StreamList', 'SearchInit', 'PaginateInit', - 'generateList', 'FormatDate', 'BuildDescription', + 'ProcessErrors', 'Wait', 'StreamList', 'generateList', 'FormatDate', 'BuildDescription', 'ShowDetail', function ($rootScope, $location, $state, Rest, GetBasePath, ProcessErrors, - Wait, StreamList, SearchInit, PaginateInit, GenerateList, FormatDate, + Wait, StreamList, GenerateList, FormatDate, BuildDescription, ShowDetail) { return function (params) { var list = _.cloneDeep(StreamList), defaultUrl = GetBasePath('activity_stream'), - view = GenerateList, - parent_scope = params.scope, - scope = parent_scope.$new(), + scope = params.scope, url = (params && params.url) ? params.url : null; $rootScope.flashMessage = null; @@ -391,13 +386,13 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti list.basePath = defaultUrl; // Generate the list - view.inject(list, { mode: 'edit', id: 'stream-content', searchSize: 'col-lg-4 col-md-4 col-sm-12 col-xs-12', secondWidget: true, activityStream: true, scope: scope }); + //view.inject(list, { mode: 'edit', id: 'stream-content', searchSize: 'col-lg-4 col-md-4 col-sm-12 col-xs-12', secondWidget: true, activityStream: true, scope: scope }); // descriptive title describing what AS is showing scope.streamTitle = (params && params.title) ? params.title : null; scope.refreshStream = function () { - scope.search(list.iterator); + $state.go('.', null, {reload: true}); }; scope.showDetail = function (id) { @@ -407,37 +402,18 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti }); }; - if (scope.removeStreamPostRefresh) { - scope.removeStreamPostRefresh(); - } - scope.removeStreamPostRefresh = scope.$on('PostRefresh', function () { - scope.activities.forEach(function(activity, i) { - // build activity.user - if (scope.activities[i].summary_fields.actor) { - scope.activities[i].user = "" + - scope.activities[i].summary_fields.actor.username + ""; - } else { - scope.activities[i].user = 'system'; - } - // build description column / action text - BuildDescription(scope.activities[i]); + scope.activities.forEach(function(activity, i) { + // build activity.user + if (scope.activities[i].summary_fields.actor) { + scope.activities[i].user = "" + + scope.activities[i].summary_fields.actor.username + ""; + } else { + scope.activities[i].user = 'system'; + } + // build description column / action text + BuildDescription(scope.activities[i]); - }); }); - - // Initialize search and paginate pieces and load data - SearchInit({ - scope: scope, - set: list.name, - list: list, - url: defaultUrl - }); - PaginateInit({ - scope: scope, - list: list, - url: defaultUrl - }); - scope.search(list.iterator); }; } ]); diff --git a/awx/ui/karma.conf.js b/awx/ui/karma.conf.js index dbeab4a306..1a22f476b2 100644 --- a/awx/ui/karma.conf.js +++ b/awx/ui/karma.conf.js @@ -20,10 +20,12 @@ module.exports = function(config) { './client/src/app.js', './node_modules/angular-mocks/angular-mocks.js', { pattern: './tests/**/*-test.js' }, + 'client/src/**/*.html' ], preprocessors: { './client/src/app.js': ['webpack', 'sourcemap'], './tests/**/*-test.js': ['webpack', 'sourcemap'], + 'client/src/**/*.html': ['html2js'] }, webpack: { plugins: [ diff --git a/awx/ui/npm-shrinkwrap.json b/awx/ui/npm-shrinkwrap.json index e22bbbc3f6..656c931f8b 100644 --- a/awx/ui/npm-shrinkwrap.json +++ b/awx/ui/npm-shrinkwrap.json @@ -182,9 +182,9 @@ } }, "angular-ui-router": { - "version": "0.2.18", - "from": "angular-ui-router@>=0.2.15 <0.3.0", - "resolved": "https://registry.npmjs.org/angular-ui-router/-/angular-ui-router-0.2.18.tgz" + "version": "1.0.0-beta.3", + "from": "angular-ui-router@>=1.0.0-beta.3 <2.0.0", + "resolved": "https://registry.npmjs.org/angular-ui-router/-/angular-ui-router-1.0.0-beta.3.tgz" }, "ansi-regex": { "version": "2.0.0", @@ -892,9 +892,9 @@ } }, "caniuse-db": { - "version": "1.0.30000568", + "version": "1.0.30000569", "from": "caniuse-db@>=1.0.30000554 <2.0.0", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000568.tgz" + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000569.tgz" }, "caseless": { "version": "0.11.0", @@ -1645,7 +1645,7 @@ }, "for-own": { "version": "0.1.4", - "from": "for-own@>=0.1.3 <0.2.0", + "from": "for-own@>=0.1.4 <0.2.0", "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.4.tgz" }, "forever-agent": { @@ -3347,9 +3347,9 @@ "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.9.2.tgz" }, "object.omit": { - "version": "2.0.0", + "version": "2.0.1", "from": "object.omit@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.0.tgz" + "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz" }, "on-finished": { "version": "2.3.0", diff --git a/awx/ui/package.json b/awx/ui/package.json index 0ad7504597..3383594e82 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -86,7 +86,7 @@ "angular-sanitize": "^1.4.3", "angular-scheduler": "chouseknecht/angular-scheduler#0.1.0", "angular-tz-extensions": "chouseknecht/angular-tz-extensions#0.3.11", - "angular-ui-router": "^0.2.15", + "angular-ui-router": "^1.0.0-beta.3", "bootstrap": "^3.1.1", "bootstrap-datepicker": "^1.4.0", "codemirror": "^5.17.0", diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index d5777c9619..918dead3c6 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -1,180 +1,148 @@ + - - - Ansible Tower - - - - - - - - - - - - - - - - - - - - + + + - + + - - -
    - - +
    + + +
    +
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - + +
    +
    +
    +

    working...

    +
    - -
    -

    working...

    - - + + diff --git a/awx/ui/tests/spec/lookup/lookup-modal.directive-test.js b/awx/ui/tests/spec/lookup/lookup-modal.directive-test.js new file mode 100644 index 0000000000..cdd4d16cc9 --- /dev/null +++ b/awx/ui/tests/spec/lookup/lookup-modal.directive-test.js @@ -0,0 +1,131 @@ +'use strict'; + +xdescribe('Directive: lookupModal', () => { + + let dom, element, listHtml, listDefinition, Dataset, + lookupTemplate, paginateTemplate, searchTemplate, columnSortTemplate, + $scope, $parent, $compile, $state; + + // mock dependency chains + // (shared requires RestServices requires Authorization etc) + beforeEach(angular.mock.module('login')); + beforeEach(angular.mock.module('shared')); + + beforeEach(angular.mock.module('LookupModalModule', ($provide) => { + $provide.value('smartSearch', angular.noop); + $provide.value('columnSort', angular.noop); + $provide.value('paginate', angular.noop); + $state = jasmine.createSpyObj('$state', ['go']); + })); + + beforeEach(angular.mock.inject(($templateCache, _$rootScope_, _$compile_, _generateList_) => { + listDefinition = { + name: 'mocks', + iterator: 'mock', + fields: { + name: {} + } + }; + + listHtml = _generateList_.build({ + mode: 'lookup', + list: listDefinition, + input_type: 'radio' + }); + + Dataset = { + data: { + results: [ + { id: 1, name: 'Mock Resource 1' }, + { id: 2, name: 'Mock Resource 2' }, + { id: 3, name: 'Mock Resource 3' }, + { id: 4, name: 'Mock Resource 4' }, + { id: 5, name: 'Mock Resource 5' }, + ] + } + }; + + dom = angular.element(`${listHtml}`); + + // populate $templateCache with directive.templateUrl at test runtime, + lookupTemplate = window.__html__['client/src/shared/lookup/lookup-modal.partial.html']; + paginateTemplate = window.__html__['client/src/shared/paginate/paginate.partial.html']; + searchTemplate = window.__html__['client/src/shared/smart-search/smart-search.partial.html']; + columnSortTemplate = window.__html__['client/src/shared/column-sort/column-sort.partial.html']; + + $templateCache.put('/static/partials/shared/lookup/lookup-modal.partial.html', lookupTemplate); + $templateCache.put('/static/partials/shared/paginate/paginate.partial.html', paginateTemplate); + $templateCache.put('/static/partials/shared/smart-search/smart-search.partial.html', searchTemplate); + $templateCache.put('/static/partials/shared/column-sort/column-sort.partial.html', columnSortTemplate); + + $compile = _$compile_; + $parent = _$rootScope_.$new(); + + // mock resolvables + $scope = $parent.$new(); + $scope.$resolve = { + ListDefinition: listDefinition, + Dataset: Dataset + }; + })); + + it('Resource is pre-selected in form - corresponding radio should initialize checked', () => { + $parent.mock = 1; // resource id + $parent.mock_name = 'Mock Resource 1'; // resource name + + console.log($scope); + + element = $compile(dom)($scope); + $scope.$digest(); + + expect($(':radio')[0].is(':checked')).toEqual(true); + }); + + it('No resource pre-selected in form - no radio should initialize checked', () => { + element = $compile(dom)($scope); + $scope.$digest(); + + _.forEach($(':radio'), (radio) => { + expect(radio.is('checked')).toEqual(false); + }); + }); + + it('Should update $parent / form scope and exit $state on save', () => { + element = $compile(dom)($scope); + $scope.$digest(); + $(':radio')[1].click(); + $('.Lookup-save')[0].click(); + + expect($parent.mock).toEqual(2); + expect($parent.mock_name).toEqual('Mock Resource 2'); + expect($state.go).toHaveBeenCalled(); + }); + + it('Should not update $parent / form scope on exit via header', () => { + $parent.mock = 3; // resource id + $parent.mock_name = 'Mock Resource 3'; // resource name + element = $compile(dom)($scope); + $scope.$digest(); + + $(':radio')[1].click(); + $('.Form-exit')[0].click(); + + expect($parent.mock).toEqual(3); + expect($parent.mock_name).toEqual('Mock Resource 3'); + expect($state.go).toHaveBeenCalled(); + }); + + it('Should not update $parent / form scope on exit via cancel button', () => { + $parent.mock = 3; // resource id + $parent.mock_name = 'Mock Resource 3'; // resource name + element = $compile(dom)($scope); + $scope.$digest(); + + $(':radio')[1].click(); + $('.Lookup-cancel')[0].click(); + + expect($parent.mock).toEqual(3); + expect($parent.mock_name).toEqual('Mock Resource 3'); + expect($state.go).toHaveBeenCalled(); + }); +}); diff --git a/awx/ui/tests/spec/paginate/paginate.directive-test.js b/awx/ui/tests/spec/paginate/paginate.directive-test.js new file mode 100644 index 0000000000..b8d1802725 --- /dev/null +++ b/awx/ui/tests/spec/paginate/paginate.directive-test.js @@ -0,0 +1,116 @@ +'use strict'; + +xdescribe('Directive: Paginate', () => { + var dom = angular.element(''), + template, + element, + $scope, + $compile, + $state, + $stateParams = {}; + + beforeEach(angular.mock.module('shared'), ($provide) =>{ + $provide.value('Rest', angular.noop); + }); + beforeEach(angular.mock.module('PaginateModule', ($provide) => { + $state = jasmine.createSpyObj('$state', ['go']); + + $provide.value('$stateParams', $stateParams); + $provide.value('Rest', angular.noop); + })); + beforeEach(angular.mock.inject(($templateCache, _$rootScope_, _$compile_) => { + // populate $templateCache with directive.templateUrl at test runtime, + template = window.__html__['client/src/shared/paginate/paginate.partial.html']; + $templateCache.put('/static/partials/shared/paginate/paginate.partial.html', template); + + $compile = _$compile_; + $scope = _$rootScope_.$new(); + })); + + it('should be hidden if only 1 page of data', () => { + + $scope.mock_dataset = {count: 19}; + $scope.pageSize = 20; + element = $compile(dom)($scope); + $scope.$digest(); + + expect($('.Paginate-wrapper', element)).hasClass('ng-hide'); + }); + describe('it should show expected page range', () => { + + + it('should show 7 pages', () =>{ + + $scope.pageSize = 1; + $scope.mock_dataset = {count: 7}; + element = $compile(dom)($scope); + $scope.$digest(); + // next, previous, 7 pages + expect($('.Paginate-controls--item', element)).length.toEqual(9); + }); + it('should show 100 pages', () =>{ + $scope.pageSize = 1; + $scope.mock_dataset = {count: 100}; + element = $compile(dom)($scope); + $scope.$digest(); + // first, next, previous, last, 100 pages + expect($('.Paginate-controls--item', element)).length.toEqual(104); + }); + }); + describe('it should get expected page', () => { + + it('should get the next page', () =>{ + + $scope.mock_dataset = { + count: 42, + }; + + $stateParams.mock_search = { + page_size: 5, + page: 1 + }; + + element = $compile(dom)($scope); + $('.Paginate-controls--next').click(); + expect($stateParams.mock_search.page).toEqual(2); + }); + + it('should get the previous page', ()=>{ + + $scope.mock_dataset = { + count: 42 + }; + $stateParams.mock_search = { + page_size: 10, + page: 3 + }; + + element = $compile(dom)($scope); + $('.Paginate-controls--previous'); + expect($stateParams.mock_search.page).toEqual(2); + }); + it('should get the last page', ()=>{ + $scope.mock_dataset = { + count: 110 + }; + $stateParams.mock_search = { + page_size: 5, + page: 1 + }; + $('.Paginate-controls--last').click(); + expect($stateParams.mock_search.page).toEqual(42); + }); + it('should get the first page', () => { + $scope.mock_dataset = { + count: 110 + }; + $stateParams.mock_search = { + page_size: 5, + page: 35 + }; + $('.Paginate-controls--first').click(); + expect($stateParams.mock_search.page).toEqual(1); + }); + + }); +}); diff --git a/awx/ui/tests/spec/smart-search/queryset.service-test.js b/awx/ui/tests/spec/smart-search/queryset.service-test.js new file mode 100644 index 0000000000..54f9794e41 --- /dev/null +++ b/awx/ui/tests/spec/smart-search/queryset.service-test.js @@ -0,0 +1,85 @@ +'use strict'; + +describe('Service: QuerySet', () => { + let $httpBackend, + QuerySet, + Authorization; + + beforeEach(angular.mock.module('Tower', ($provide) =>{ + // @todo: improve app source / write testing utilities for interim + // we don't want to be concerned with this provision in every test that involves the Rest module + Authorization = { + getToken: () => true, + isUserLoggedIn: angular.noop + }; + $provide.value('LoadBasePaths', angular.noop); + $provide.value('Authorization', Authorization); + })); + beforeEach(angular.mock.module('RestServices')); + + beforeEach(angular.mock.inject((_$httpBackend_, _QuerySet_) => { + $httpBackend = _$httpBackend_; + QuerySet = _QuerySet_; + + // @todo: improve app source + // config.js / local_settings emit $http requests in the app's run block + $httpBackend + .whenGET(/\/static\/*/) + .respond(200, {}); + // @todo: improve appsource + // provide api version via package.json config block + $httpBackend + .whenGET('/api/') + .respond(200, ''); + })); + + describe('fn encodeQuery', () => { + xit('null/undefined params should return an empty string', () => { + expect(QuerySet.encodeQuery(null)).toEqual(''); + expect(QuerySet.encodeQuery(undefined)).toEqual(''); + }); + xit('should encode params to a string', () => { + let params = { + or__created_by: 'Jenkins', + or__modified_by: 'Jenkins', + and__not__status: 'success', + }, + result = '?or__created_by=Jenkins&or__modified_by=Jenkins&and__not__status=success'; + expect(QuerySet.encodeQuery(params)).toEqual(result); + }); + }); + + xdescribe('fn decodeQuery', () => { + + }); + + + describe('fn search', () => { + let pattern = /\/api\/v1\/inventories\/(.+)\/groups\/*/, + endpoint = '/api/v1/inventories/1/groups/', + params = { + or__name: 'borg', + or__description__icontains: 'assimilate' + }; + + it('should GET expected URL', () =>{ + $httpBackend + .expectGET(pattern) + .respond(200, {}); + QuerySet.search(endpoint, params).then((data) =>{ + expect(data.config.url).toEqual('/api/v1/inventories/1/groups/?or__name=borg&or__description__icontains=assimilate'); + }); + $httpBackend.flush(); + }); + + xit('should memoize new DjangoModel', ()=>{}); + xit('should not replace memoized DjangoModel', ()=>{}); + xit('should provide an alias interface', ()=>{}); + + afterEach(() => { + $httpBackend.verifyNoOutstandingExpectation(); + $httpBackend.verifyNoOutstandingRequest(); + }); + }); + +}); diff --git a/awx/ui/tests/spec/smart-search/smart-search.directive-test.js b/awx/ui/tests/spec/smart-search/smart-search.directive-test.js new file mode 100644 index 0000000000..59a701e9bc --- /dev/null +++ b/awx/ui/tests/spec/smart-search/smart-search.directive-test.js @@ -0,0 +1,163 @@ +'use strict'; + +xdescribe('Directive: Smart Search', () => { + let $scope, + template, + element, + dom, + $compile, + $state = {}, + $stateParams, + GetBasePath, + QuerySet; + + beforeEach(angular.mock.module('shared')); + beforeEach(angular.mock.module('SmartSearchModule', ($provide) => { + QuerySet = jasmine.createSpyObj('QuerySet', ['decodeParam']); + QuerySet.decodeParam.and.callFake((key, value) => { + return `${key.split('__').join(':')}:${value}`; + }); + GetBasePath = jasmine.createSpy('GetBasePath'); + + $provide.value('QuerySet', QuerySet); + $provide.value('GetBasePath', GetBasePath); + $provide.value('$state', $state); + })); + beforeEach(angular.mock.inject(($templateCache, _$rootScope_, _$compile_) => { + // populate $templateCache with directive.templateUrl at test runtime, + template = window.__html__['client/src/shared/smart-search/smart-search.partial.html']; + $templateCache.put('/static/partials/shared/smart-search/smart-search.partial.html', template); + + $compile = _$compile_; + $scope = _$rootScope_.$new(); + })); + + describe('initializing tags', () => { + beforeEach(() => { + QuerySet.initFieldset = function() { + return { + then: function() { + return; + } + }; + }; + }); + // some defaults like page_size and page will always be provided + // but should be squashed if initialized with default values + it('should not create tags', () => { + $state.$current = { + params: { + mock_search: { + config: { + value: { + page_size: '20', + order_by: '-finished', + page: '1' + } + } + } + } + }; + $state.params = { + mock_search: { + page_size: '20', + order_by: '-finished', + page: '1' + } + }; + dom = angular.element(` + `); + element = $compile(dom)($scope); + $scope.$digest(); + expect($('.SmartSearch-tagContainer', element).length).toEqual(0); + }); + // set one possible default (order_by) with a custom value, but not another default (page_size) + it('should create an order_by tag, but not a page_size tag', () => { + $state.$current = { + params: { + mock_search: { + config: { + value: { + page_size: '20', + order_by: '-finished' + } + } + } + } + }; + $state.params = { + mock_search: { + page_size: '20', + order_by: 'name' + } + }; + dom = angular.element(` + `); + element = $compile(dom)($scope); + $scope.$digest(); + expect($('.SmartSearch-tagContainer', element).length).toEqual(1); + expect($('.SmartSearch-tagContainer .SmartSearch-name', element)[0].innerText).toEqual('order_by:name'); + }); + // set many possible defaults and many non-defaults - page_size and page shouldn't generate tags, even when non-default values are set + it('should create an order_by tag, name tag, description tag - but not a page_size or page tag', () => { + $state.$current = { + params: { + mock_search: { + config: { + value: { + page_size: '20', + order_by: '-finished', + page: '1' + } + } + } + } + }; + $state.params = { + mock_search: { + page_size: '25', + order_by: 'name', + page: '11', + description_icontains: 'ansible', + name_icontains: 'ansible' + } + }; + dom = angular.element(` + `); + element = $compile(dom)($scope); + $scope.$digest(); + expect($('.SmartSearch-tagContainer', element).length).toEqual(3); + }); + }); + + describe('removing tags', () => { + // assert a default value is still provided after a custom tag is removed + xit('should revert to state-defined order_by when order_by tag is removed', () => {}); + }); + + describe('accessing model', () => { + xit('should retrieve cached model OPTIONS from localStorage', () => {}); + xit('should call QuerySet service to retrieve unstored model OPTIONS', () => {}); + }); +}); From d253eabe5dcd43830dfa288817504dba9315b236 Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Fri, 28 Oct 2016 15:39:48 -0400 Subject: [PATCH 08/14] Sockets - avoid sending invalid JSON when entering state with no socket definition. --- awx/ui/client/src/shared/socket/socket.service.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index af76fc736b..1b7baf597a 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -135,7 +135,7 @@ export default // to the API: {"groups": {}}. // This is used for all pages that are socket-disabled if(this.requiresNewSubscribe(state)){ - this.emit(JSON.stringify(state.data.socket)); + this.emit(JSON.stringify(state.data.socket) || JSON.stringify({"groups": {}})); } this.setLast(state); }, @@ -205,8 +205,8 @@ export default // requires a subscribe or an unsubscribe var self = this; socketPromise.promise.then(function(){ - if(!state.data && !state.data.socket){ - state.data.socket = {groups: {}}; + if(!state.data || !state.data.socket){ + _.merge(state.data, {socket: {groups: {}}}); self.unsubscribe(state); } else{ From c18b6c13521ce19c153707c7ae34a537553af47d Mon Sep 17 00:00:00 2001 From: Chris Church Date: Sat, 22 Oct 2016 00:15:49 -0400 Subject: [PATCH 09/14] Add support for capturing stdout associated with job events and ad hoc command events. * New event types for stdout lines not associated with a callback event. * New stdout, start_line, end_line and verbosity fields for job/ahc events. * Callback plugins to wrap Ansible default/minimal stdout callbacks and embed callback event data using ANSI escape sequences. * Callback plugin library to wrap ansible.display.Display class methods. * Output filter to extract event data from stdout and create job/ahc events. * Update stdout formats to strip new ANSI escape sequences. --- awx/api/permissions.py | 2 - awx/api/serializers.py | 16 +- awx/api/views.py | 39 +- awx/lib/sitecustomize.py | 22 + awx/lib/tower_display_callback/__init__.py | 25 + awx/lib/tower_display_callback/cleanup.py | 72 +++ awx/lib/tower_display_callback/display.py | 92 +++ awx/lib/tower_display_callback/events.py | 138 +++++ awx/lib/tower_display_callback/minimal.py | 28 + awx/lib/tower_display_callback/module.py | 443 ++++++++++++++ .../commands/run_callback_receiver.py | 117 +--- .../migrations/0044_v310_job_event_stdout.py | 96 +++ awx/main/models/ad_hoc_commands.py | 67 +- awx/main/models/jobs.py | 313 ++++++---- awx/main/models/unified_jobs.py | 7 +- awx/main/tasks.py | 50 +- awx/main/utils.py | 71 ++- awx/plugins/callback/job_event_callback.py | 579 ------------------ awx/plugins/callback/minimal.py | 30 + awx/plugins/callback/tower_display.py | 30 + 20 files changed, 1387 insertions(+), 850 deletions(-) create mode 100644 awx/lib/sitecustomize.py create mode 100644 awx/lib/tower_display_callback/__init__.py create mode 100644 awx/lib/tower_display_callback/cleanup.py create mode 100644 awx/lib/tower_display_callback/display.py create mode 100644 awx/lib/tower_display_callback/events.py create mode 100644 awx/lib/tower_display_callback/minimal.py create mode 100644 awx/lib/tower_display_callback/module.py create mode 100644 awx/main/migrations/0044_v310_job_event_stdout.py delete mode 100644 awx/plugins/callback/job_event_callback.py create mode 100644 awx/plugins/callback/minimal.py create mode 100644 awx/plugins/callback/tower_display.py diff --git a/awx/api/permissions.py b/awx/api/permissions.py index ecd725bc6e..cda66ff2ec 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -189,8 +189,6 @@ class TaskPermission(ModelAccessPermission): # token. if view.model == Inventory and request.method.lower() in ('head', 'get'): return bool(not obj or obj.pk == unified_job.inventory_id) - elif view.model in (JobEvent, AdHocCommandEvent) and request.method.lower() == 'post': - return bool(not obj or obj.pk == unified_job.pk) else: return False diff --git a/awx/api/serializers.py b/awx/api/serializers.py index eaa764c345..3677f1a028 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2417,7 +2417,9 @@ class JobEventSerializer(BaseSerializer): model = JobEvent fields = ('*', '-name', '-description', 'job', 'event', 'counter', 'event_display', 'event_data', 'event_level', 'failed', - 'changed', 'host', 'host_name', 'parent', 'play', 'task', 'role') + 'changed', 'uuid', 'host', 'host_name', 'parent', 'playbook', + 'play', 'task', 'role', 'stdout', 'start_line', 'end_line', + 'verbosity') def get_related(self, obj): res = super(JobEventSerializer, self).get_related(obj) @@ -2453,16 +2455,8 @@ class AdHocCommandEventSerializer(BaseSerializer): model = AdHocCommandEvent fields = ('*', '-name', '-description', 'ad_hoc_command', 'event', 'counter', 'event_display', 'event_data', 'failed', - 'changed', 'host', 'host_name') - - def to_internal_value(self, data): - ret = super(AdHocCommandEventSerializer, self).to_internal_value(data) - # AdHocCommandAdHocCommandEventsList should be the only view creating - # AdHocCommandEvent instances, so keep the ad_hoc_command it sets, even - # though ad_hoc_command is a read-only field. - if 'ad_hoc_command' in data: - ret['ad_hoc_command'] = data['ad_hoc_command'] - return ret + 'changed', 'uuid', 'host', 'host_name', 'stdout', + 'start_line', 'end_line', 'verbosity') def get_related(self, obj): res = super(AdHocCommandEventSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index 66980e141d..c95cebcb9e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -10,8 +10,9 @@ import time import socket import sys import logging -from base64 import b64encode +from base64 import b64encode, b64decode from collections import OrderedDict +from HTMLParser import HTMLParser # Django from django.conf import settings @@ -3050,21 +3051,6 @@ class GroupJobEventsList(BaseJobEventsList): class JobJobEventsList(BaseJobEventsList): parent_model = Job - authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (TaskPermission,) - - # Post allowed for job event callback only. - def post(self, request, *args, **kwargs): - parent_obj = get_object_or_404(self.parent_model, pk=self.kwargs['pk']) - data = request.data.copy() - data['job'] = parent_obj.pk - serializer = self.get_serializer(data=data) - if serializer.is_valid(): - self.instance = serializer.save() - headers = {'Location': serializer.data['url']} - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class JobJobPlaysList(BaseJobEventsList): @@ -3455,25 +3441,8 @@ class HostAdHocCommandEventsList(BaseAdHocCommandEventsList): class AdHocCommandAdHocCommandEventsList(BaseAdHocCommandEventsList): parent_model = AdHocCommand - authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (TaskPermission,) new_in_220 = True - # Post allowed for ad hoc event callback only. - def post(self, request, *args, **kwargs): - if request.user: - raise PermissionDenied() - parent_obj = get_object_or_404(self.parent_model, pk=self.kwargs['pk']) - data = request.data.copy() - data['ad_hoc_command'] = parent_obj - serializer = self.get_serializer(data=data) - if serializer.is_valid(): - self.instance = serializer.save() - headers = {'Location': serializer.data['url']} - return Response(serializer.data, status=status.HTTP_201_CREATED, - headers=headers) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - class AdHocCommandActivityStreamList(SubListAPIView): @@ -3583,7 +3552,11 @@ class UnifiedJobStdout(RetrieveAPIView): dark_bg = (content_only and dark) or (not content_only and (dark or not dark_val)) content, start, end, absolute_end = unified_job.result_stdout_raw_limited(start_line, end_line) + # Remove any ANSI escape sequences containing job event data. + content = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', content) + body = ansiconv.to_html(cgi.escape(content)) + context = { 'title': get_view_name(self.__class__), 'body': mark_safe(body), diff --git a/awx/lib/sitecustomize.py b/awx/lib/sitecustomize.py new file mode 100644 index 0000000000..cf6cab211e --- /dev/null +++ b/awx/lib/sitecustomize.py @@ -0,0 +1,22 @@ +# Python +import os +import sys + +# Based on http://stackoverflow.com/a/6879344/131141 -- Initialize tower display +# callback as early as possible to wrap ansible.display.Display methods. + +def argv_ready(argv): + if argv and os.path.basename(argv[0]) in {'ansible', 'ansible-playbook'}: + import tower_display_callback + + +class argv_placeholder(object): + + def __del__(self): + argv_ready(sys.argv) + + +if hasattr(sys, 'argv'): + argv_ready(sys.argv) +else: + sys.argv = argv_placeholder() diff --git a/awx/lib/tower_display_callback/__init__.py b/awx/lib/tower_display_callback/__init__.py new file mode 100644 index 0000000000..313a1d50c6 --- /dev/null +++ b/awx/lib/tower_display_callback/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Tower Display Callback +from . import cleanup # to register control persistent cleanup. +from . import display # to wrap ansible.display.Display methods. +from .module import TowerDefaultCallbackModule, TowerMinimalCallbackModule + +__all__ = ['TowerDefaultCallbackModule', 'TowerMinimalCallbackModule'] diff --git a/awx/lib/tower_display_callback/cleanup.py b/awx/lib/tower_display_callback/cleanup.py new file mode 100644 index 0000000000..1bc276f742 --- /dev/null +++ b/awx/lib/tower_display_callback/cleanup.py @@ -0,0 +1,72 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import atexit +import glob +import os +import pwd + +# PSUtil +import psutil + +__all__ = [] + + +@atexit.register +def terminate_ssh_control_masters(): + # Determine if control persist is being used and if any open sockets + # exist after running the playbook. + cp_path = os.environ.get('ANSIBLE_SSH_CONTROL_PATH', '') + if not cp_path: + return + cp_dir = os.path.dirname(cp_path) + if not os.path.exists(cp_dir): + return + cp_pattern = os.path.join(cp_dir, 'ansible-ssh-*') + cp_files = glob.glob(cp_pattern) + if not cp_files: + return + + # Attempt to find any running control master processes. + username = pwd.getpwuid(os.getuid())[0] + ssh_cm_procs = [] + for proc in psutil.process_iter(): + try: + pname = proc.name() + pcmdline = proc.cmdline() + pusername = proc.username() + except psutil.NoSuchProcess: + continue + if pusername != username: + continue + if pname != 'ssh': + continue + for cp_file in cp_files: + if pcmdline and cp_file in pcmdline[0]: + ssh_cm_procs.append(proc) + break + + # Terminate then kill control master processes. Workaround older + # version of psutil that may not have wait_procs implemented. + for proc in ssh_cm_procs: + proc.terminate() + procs_gone, procs_alive = psutil.wait_procs(ssh_cm_procs, timeout=5) + for proc in procs_alive: + proc.kill() \ No newline at end of file diff --git a/awx/lib/tower_display_callback/display.py b/awx/lib/tower_display_callback/display.py new file mode 100644 index 0000000000..ec32fec334 --- /dev/null +++ b/awx/lib/tower_display_callback/display.py @@ -0,0 +1,92 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import cgi +import contextlib +import functools +import json +import sys +import uuid + +# Ansible +from ansible.utils.display import Display + +# Tower Display Callback +from tower_display_callback.events import event_context + +__all__ = [] + + +def with_context(**context): + global event_context + def wrap(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + with event_context.set_local(**context): + return f(*args, **kwargs) + return wrapper + return wrap + + +for attr in dir(Display): + if attr.startswith('_') or 'cow' in attr or 'prompt' in attr: + continue + if attr in ('display', 'v', 'vv', 'vvv', 'vvvv', 'vvvvv', 'vvvvvv', 'verbose'): + continue + if not callable(getattr(Display, attr)): + continue + setattr(Display, attr, with_context(**{attr: True})(getattr(Display, attr))) + + +def with_verbosity(f): + global event_context + @functools.wraps(f) + def wrapper(*args, **kwargs): + host = args[2] if len(args) >= 3 else kwargs.get('host', None) + caplevel = args[3] if len(args) >= 4 else kwargs.get('caplevel', 2) + context = dict(verbose=True, verbosity=(caplevel + 1)) + if host is not None: + context['remote_addr'] = host + with event_context.set_local(**context): + return f(*args, **kwargs) + return wrapper + +Display.verbose = with_verbosity(Display.verbose) + + +def display_with_context(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + log_only = args[5] if len(args) >= 6 else kwargs.get('log_only', False) + stderr = args[3] if len(args) >= 4 else kwargs.get('stderr', False) + fileobj = sys.stderr if stderr else sys.stdout + event_uuid = event_context.get().get('uuid', None) + try: + if not log_only and not event_uuid: + event_context.add_local(uuid=str(uuid.uuid4())) + event_context.dump_begin(fileobj) + return f(*args, **kwargs) + finally: + if not log_only and not event_uuid: + event_context.dump_end(fileobj) + event_context.remove_local(uuid=None) + return wrapper + +Display.display = display_with_context(Display.display) diff --git a/awx/lib/tower_display_callback/events.py b/awx/lib/tower_display_callback/events.py new file mode 100644 index 0000000000..fa664e5856 --- /dev/null +++ b/awx/lib/tower_display_callback/events.py @@ -0,0 +1,138 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import base64 +import cgi +import contextlib +import datetime +import json +import os +import threading +import uuid + +__all__ = ['event_context'] + + +class EventContext(object): + ''' + Store global and local (per thread/process) data associated with callback + events and other display output methods. + ''' + + def add_local(self, **kwargs): + if not hasattr(self, '_local'): + self._local = threading.local() + self._local._ctx = {} + self._local._ctx.update(kwargs) + + def remove_local(self, **kwargs): + if hasattr(self, '_local'): + for key in kwargs.keys(): + self._local._ctx.pop(key, None) + + @contextlib.contextmanager + def set_local(self, **kwargs): + try: + self.add_local(**kwargs) + yield + finally: + self.remove_local(**kwargs) + + def get_local(self): + return getattr(getattr(self, '_local', None), '_ctx', {}) + + def add_global(self, **kwargs): + if not hasattr(self, '_global_ctx'): + self._global_ctx = {} + self._global_ctx.update(kwargs) + + def remove_global(self, **kwargs): + if hasattr(self, '_global_ctx'): + for key in kwargs.keys(): + self._global_ctx.pop(key, None) + + @contextlib.contextmanager + def set_global(self, **kwargs): + try: + self.add_global(**kwargs) + yield + finally: + self.remove_global(**kwargs) + + def get_global(self): + return getattr(self, '_global_ctx', {}) + + def get(self): + ctx = {} + ctx.update(self.get_global()) + ctx.update(self.get_local()) + return ctx + + def get_begin_dict(self): + event_data = self.get() + if os.getenv('JOB_ID', ''): + event_data['job_id'] = int(os.getenv('JOB_ID', '0')) + if os.getenv('AD_HOC_COMMAND_ID', ''): + event_data['ad_hoc_command_id'] = int(os.getenv('AD_HOC_COMMAND_ID', '0')) + event_data.setdefault('pid', os.getpid()) + event_data.setdefault('uuid', str(uuid.uuid4())) + event_data.setdefault('created', datetime.datetime.utcnow().isoformat()) + if not event_data.get('parent_uuid', None) and event_data.get('job_id', None): + for key in ('task_uuid', 'play_uuid', 'playbook_uuid'): + parent_uuid = event_data.get(key, None) + if parent_uuid and parent_uuid != event_data.get('uuid', None): + event_data['parent_uuid'] = parent_uuid + break + + event = event_data.pop('event', None) + if not event: + event = 'verbose' + for key in ('debug', 'verbose', 'deprecated', 'warning', 'system_warning', 'error'): + if event_data.get(key, False): + event = key + break + + event_dict = dict(event=event, event_data=event_data) + for key in event_data.keys(): + if key in ('job_id', 'ad_hoc_command_id', 'uuid', 'parent_uuid', 'created', 'artifact_data'): + event_dict[key] = event_data.pop(key) + elif key in ('verbosity', 'pid'): + event_dict[key] = event_data[key] + return event_dict + + def get_end_dict(self): + return {} + + def dump(self, fileobj, data, max_width=78): + b64data = base64.b64encode(json.dumps(data)) + fileobj.write(u'\x1b[K') + for offset in xrange(0, len(b64data), max_width): + chunk = b64data[offset:offset + max_width] + escaped_chunk = u'{}\x1b[{}D'.format(chunk, len(chunk)) + fileobj.write(escaped_chunk) + fileobj.write(u'\x1b[K') + + def dump_begin(self, fileobj): + self.dump(fileobj, self.get_begin_dict()) + + def dump_end(self, fileobj): + self.dump(fileobj, self.get_end_dict()) + +event_context = EventContext() diff --git a/awx/lib/tower_display_callback/minimal.py b/awx/lib/tower_display_callback/minimal.py new file mode 100644 index 0000000000..de7694213e --- /dev/null +++ b/awx/lib/tower_display_callback/minimal.py @@ -0,0 +1,28 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import os + +# Ansible +import ansible + +# Because of the way Ansible loads plugins, it's not possible to import +# ansible.plugins.callback.minimal when being loaded as the minimal plugin. Ugh. +execfile(os.path.join(os.path.dirname(ansible.__file__), 'plugins', 'callback', 'minimal.py')) diff --git a/awx/lib/tower_display_callback/module.py b/awx/lib/tower_display_callback/module.py new file mode 100644 index 0000000000..7be4835aa7 --- /dev/null +++ b/awx/lib/tower_display_callback/module.py @@ -0,0 +1,443 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import contextlib +import copy +import os +import re +import sys +import uuid + +# Ansible +from ansible.plugins.callback import CallbackBase +from ansible.plugins.callback.default import CallbackModule as DefaultCallbackModule + +# Tower Display Callback +from tower_display_callback.events import event_context +from tower_display_callback.minimal import CallbackModule as MinimalCallbackModule + + +class BaseCallbackModule(CallbackBase): + ''' + Callback module for logging ansible/ansible-playbook events. + ''' + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'stdout' + + # These events should never have an associated play. + EVENTS_WITHOUT_PLAY = [ + 'playbook_on_start', + 'playbook_on_stats', + ] + + # These events should never have an associated task. + EVENTS_WITHOUT_TASK = EVENTS_WITHOUT_PLAY + [ + 'playbook_on_setup', + 'playbook_on_notify', + 'playbook_on_import_for_host', + 'playbook_on_not_import_for_host', + 'playbook_on_no_hosts_matched', + 'playbook_on_no_hosts_remaining', + ] + + CENSOR_FIELD_WHITELIST = [ + 'msg', + 'failed', + 'changed', + 'results', + 'start', + 'end', + 'delta', + 'cmd', + '_ansible_no_log', + 'rc', + 'failed_when_result', + 'skipped', + 'skip_reason', + ] + + def __init__(self): + super(BaseCallbackModule, self).__init__() + self.task_uuids = set() + + def censor_result(self, res, no_log=False): + if not isinstance(res, dict): + if no_log: + return "the output has been hidden due to the fact that 'no_log: true' was specified for this result" + return res + if res.get('_ansible_no_log', no_log): + new_res = {} + for k in self.CENSOR_FIELD_WHITELIST: + if k in res: + new_res[k] = res[k] + if k == 'cmd' and k in res: + if isinstance(res['cmd'], list): + res['cmd'] = ' '.join(res['cmd']) + if re.search(r'\s', res['cmd']): + new_res['cmd'] = re.sub(r'^(([^\s\\]|\\\s)+).*$', + r'\1 ', + res['cmd']) + new_res['censored'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" + res = new_res + if 'results' in res: + if isinstance(res['results'], list): + for i in xrange(len(res['results'])): + res['results'][i] = self.censor_result(res['results'][i], res.get('_ansible_no_log', no_log)) + elif res.get('_ansible_no_log', False): + res['results'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" + return res + + @contextlib.contextmanager + def capture_event_data(self, event, **event_data): + + event_data.setdefault('uuid', str(uuid.uuid4())) + + if 'res' in event_data: + event_data['res'] = self.censor_result(copy.deepcopy(event_data['res'])) + res = event_data.get('res', None) + if res and isinstance(res, dict): + if 'artifact_data' in res: + event_data['artifact_data'] = res['artifact_data'] + + if event not in self.EVENTS_WITHOUT_TASK: + task = event_data.pop('task', None) + else: + task = None + + try: + event_context.add_local(event=event, **event_data) + if task: + self.set_task(task, local=True) + event_context.dump_begin(sys.stdout) + yield + finally: + event_context.dump_end(sys.stdout) + if task: + self.clear_task(local=True) + event_context.remove_local(event=None, **event_data) + + def set_playbook(self, playbook): + # NOTE: Ansible doesn't generate a UUID for playbook_on_start so do it for them. + self.playbook_uuid = str(uuid.uuid4()) + file_name = getattr(playbook, '_file_name', '???') + event_context.add_global(playbook=file_name, playbook_uuid=self.playbook_uuid) + self.clear_play() + + def set_play(self, play): + if hasattr(play, 'hosts'): + if isinstance(play.hosts, list): + pattern = ','.join(play.hosts) + else: + pattern = play.hosts + else: + pattern = '' + name = play.get_name().strip() or pattern + event_context.add_global(play=name, play_uuid=str(play._uuid), play_pattern=pattern) + self.clear_task() + + def clear_play(self): + event_context.remove_global(play=None, play_uuid=None, play_pattern=None) + self.clear_task() + + def set_task(self, task, local=False): + # FIXME: Task is "global" unless using free strategy! + task_ctx = dict( + task=(task.name or task.action), + task_path=task.get_path(), + task_uuid=str(task._uuid), + task_action=task.action, + ) + if not task.no_log: + task_args = ', '.join(('%s=%s' % a for a in task.args.items())) + task_ctx['task_args'] = task_args + if getattr(task, '_role', None): + task_role = task._role._role_name + else: + task_role = getattr(task, 'role_name', '') + if task_role: + task_ctx['role'] = task_role + if local: + event_context.add_local(**task_ctx) + else: + event_context.add_global(**task_ctx) + + def clear_task(self, local=False): + task_ctx = dict(task=None, task_path=None, task_uuid=None, task_action=None, task_args=None, role=None) + if local: + event_context.remove_local(**task_ctx) + else: + event_context.remove_global(**task_ctx) + + def v2_playbook_on_start(self, playbook): + self.set_playbook(playbook) + event_data = dict( + uuid=self.playbook_uuid, + ) + with self.capture_event_data('playbook_on_start', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_start(playbook) + + def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, + encrypt=None, confirm=False, salt_size=None, + salt=None, default=None): + return # not currently used in v2 (yet) - FIXME: Confirm this is still the case? + event_data = dict( + varname=varname, + private=private, + prompt=prompt, + encrypt=encrypt, + confirm=confirm, + salt_size=salt_size, + salt=salt, + default=default, + ) + with self.capture_event_data('playbook_on_vars_prompt', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_vars_prompt(varname, + private, prompt, encrypt, confirm, salt_size, salt, default) + + def v2_playbook_on_include(self, included_file): + event_data = dict( + included_file=included_file, + ) + with self.capture_event_data('playbook_on_include', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_include(included_file) + + def v2_playbook_on_play_start(self, play): + self.set_play(play) + if hasattr(play, 'hosts'): + if isinstance(play.hosts, list): + pattern = ','.join(play.hosts) + else: + pattern = play.hosts + else: + pattern = '' + name = play.get_name().strip() or pattern + event_data = dict( + name=name, + pattern=pattern, + uuid=str(play._uuid), + ) + with self.capture_event_data('playbook_on_play_start', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_play_start(play) + + def v2_playbook_on_import_for_host(self, result, imported_file): + return # not currently used in v2 (yet) / don't care about recording this one + with self.capture_event_data('playbook_on_import_for_host'): + super(BaseCallbackModule, self).v2_playbook_on_import_for_host(result, imported_file) + + def v2_playbook_on_not_import_for_host(self, result, missing_file): + return # not currently used in v2 (yet) / don't care about recording this one + with self.capture_event_data('playbook_on_not_import_for_host'): + super(BaseCallbackModule, self).v2_playbook_on_not_import_for_host(result, missing_file) + + def v2_playbook_on_setup(self): + return # not currently used in v2 (yet) + with self.capture_event_data('playbook_on_setup'): + super(BaseCallbackModule, self).v2_playbook_on_setup() + + def v2_playbook_on_task_start(self, task, is_conditional): + # FIXME: Flag task path output as vv. + task_uuid = str(task._uuid) + if task_uuid in self.task_uuids: + return + self.task_uuids.add(task_uuid) + self.set_task(task) + event_data = dict( + task=task, + name=task.get_name(), + is_conditional=is_conditional, + uuid=task_uuid, + ) + with self.capture_event_data('playbook_on_task_start', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_task_start(task, is_conditional) + + def v2_playbook_on_cleanup_task_start(self, task): + # re-using playbook_on_task_start event here for this v2-specific + # event, though we may consider any changes necessary to distinguish + # this from a normal task FIXME! + self.set_task(task) + event_data = dict( + task=task, + name=task.get_name(), + uuid=str(task._uuid), + ) + with self.capture_event_data('playbook_on_task_start', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_cleanup_task_start(task) + + def v2_playbook_on_handler_task_start(self, task): + # re-using playbook_on_task_start event here for this v2-specific + # event, though we may consider any changes necessary to distinguish + # this from a normal task FIXME! + self.set_task(task) + event_data = dict( + task=task, + name=task.get_name(), + uuid=str(task._uuid), + ) + with self.capture_event_data('playbook_on_task_start', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_handler_task_start(task) + + def v2_playbook_on_no_hosts_matched(self): + with self.capture_event_data('playbook_on_no_hosts_matched'): + super(BaseCallbackModule, self).v2_playbook_on_no_hosts_matched() + + def v2_playbook_on_no_hosts_remaining(self): + with self.capture_event_data('playbook_on_no_hosts_remaining'): + super(BaseCallbackModule, self).v2_playbook_on_no_hosts_remaining() + + def v2_playbook_on_notify(self, result, handler): + event_data = dict( + host=result._host.name, + task=result._task, + handler=handler, + ) + with self.capture_event_data('playbook_on_notify', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_notify(result, handler) + + def v2_playbook_on_stats(self, stats): + self.clear_play() + # FIXME: Add count of plays/tasks. + event_data = dict( + changed=stats.changed, + dark=stats.dark, + failures=stats.failures, + ok=stats.ok, + processed=stats.processed, + skipped=stats.skipped, + ) + with self.capture_event_data('playbook_on_stats', **event_data): + super(BaseCallbackModule, self).v2_playbook_on_stats(stats) + + def v2_runner_on_ok(self, result): + # FIXME: Display detailed results or not based on verbosity. + event_data = dict( + host=result._host.name, + remote_addr=result._host.address, + task=result._task, + res=result._result, + event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + ) + with self.capture_event_data('runner_on_ok', **event_data): + super(BaseCallbackModule, self).v2_runner_on_ok(result) + + def v2_runner_on_failed(self, result, ignore_errors=False): + # FIXME: Add verbosity for exception/results output. + event_data = dict( + host=result._host.name, + res=result._result, + task=result._task, + ignore_errors=ignore_errors, + event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + ) + with self.capture_event_data('runner_on_failed', **event_data): + super(BaseCallbackModule, self).v2_runner_on_failed(result, ignore_errors) + + def v2_runner_on_error(self, result): + pass # Not implemented in v2. + + def v2_runner_on_skipped(self, result): + event_data = dict( + host=result._host.name, + task=result._task, + event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + ) + with self.capture_event_data('runner_on_skipped', **event_data): + super(BaseCallbackModule, self).v2_runner_on_skipped(result) + + def v2_runner_on_unreachable(self, result): + event_data = dict( + host=result._host.name, + task=result._task, + res=result._result, + ) + with self.capture_event_data('runner_on_unreachable', **event_data): + super(BaseCallbackModule, self).v2_runner_on_unreachable(result) + + def v2_runner_on_no_hosts(self, task): + event_data = dict( + task=task, + ) + with self.capture_event_data('runner_on_no_hosts', **event_data): + super(BaseCallbackModule, self).v2_runner_on_no_hosts(task) + + def v2_runner_on_file_diff(self, result, diff): + # FIXME: Ignore file diff for ad hoc commands? + event_data = dict( + host=result._host.name, + task=result._task, + diff=diff, + ) + with self.capture_event_data('runner_on_file_diff', **event_data): + super(BaseCallbackModule, self).v2_runner_on_file_diff(result, diff) + + def v2_runner_item_on_ok(self, result): + event_data = dict( + host=result._host.name, + task=result._task, + res=result._result, + ) + with self.capture_event_data('runner_item_on_ok', **event_data): + super(BaseCallbackModule, self).v2_runner_item_on_ok(result) + + def v2_runner_item_on_failed(self, result): + event_data = dict( + host=result._host.name, + task=result._task, + res=result._result, + ) + with self.capture_event_data('runner_item_on_failed', **event_data): + super(BaseCallbackModule, self).v2_runner_item_on_failed(result) + + def v2_runner_item_on_skipped(self, result): + event_data = dict( + host=result._host.name, + task=result._task, + res=result._result, + ) + with self.capture_event_data('runner_item_on_skipped', **event_data): + super(BaseCallbackModule, self).v2_runner_item_on_skipped(result) + + # V2 does not use the _on_async callbacks (yet). + + def runner_on_async_poll(self, host, res, jid, clock): + self._log_event('runner_on_async_poll', host=host, res=res, jid=jid, + clock=clock) + + def runner_on_async_ok(self, host, res, jid): + self._log_event('runner_on_async_ok', host=host, res=res, jid=jid) + + def runner_on_async_failed(self, host, res, jid): + self._log_event('runner_on_async_failed', host=host, res=res, jid=jid) + + +class TowerDefaultCallbackModule(BaseCallbackModule, DefaultCallbackModule): + + CALLBACK_NAME = 'tower_display' + + +class TowerMinimalCallbackModule(BaseCallbackModule, MinimalCallbackModule): + + CALLBACK_NAME = 'minimal' + + def v2_playbook_on_play_start(self, play): + pass + + def v2_playbook_on_task_start(self, task, is_conditional): + self.set_task(task) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index 4f777cd40e..e99a34aa4f 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -2,7 +2,6 @@ # All Rights Reserved. # Python -import datetime import logging import json @@ -12,10 +11,7 @@ from kombu.mixins import ConsumerMixin # Django from django.conf import settings from django.core.management.base import NoArgsCommand -from django.core.cache import cache from django.db import DatabaseError -from django.utils.dateparse import parse_datetime -from django.utils.timezone import FixedOffset # AWX from awx.main.models import * # noqa @@ -36,112 +32,26 @@ class CallbackBrokerWorker(ConsumerMixin): def process_task(self, body, message): try: - if "event" not in body: - raise Exception("Payload does not have an event") - if "job_id" not in body: - raise Exception("Payload does not have a job_id") + if 'event' not in body: + raise Exception('Payload does not have an event') + if 'job_id' not in body and 'ad_hoc_command_id' not in body: + raise Exception('Payload does not have a job_id or ad_hoc_command_id') if settings.DEBUG: - logger.info("Body: {}".format(body)) - logger.info("Message: {}".format(message)) - self.process_job_event(body) + logger.info('Body: {}'.format(body)) + logger.info('Message: {}'.format(message)) + try: + if 'job_id' in body: + JobEvent.create_from_data(**body) + elif 'ad_hoc_command_id' in body: + AdHocCommandEvent.create_from_data(**body) + except DatabaseError as e: + logger.error('Database Error Saving Job Event: {}'.format(e)) except Exception as exc: import traceback traceback.print_exc() logger.error('Callback Task Processor Raised Exception: %r', exc) message.ack() - def process_job_event(self, payload): - # Get the correct "verbose" value from the job. - # If for any reason there's a problem, just use 0. - if 'ad_hoc_command_id' in payload: - event_type_key = 'ad_hoc_command_id' - event_object_type = AdHocCommand - else: - event_type_key = 'job_id' - event_object_type = Job - - try: - verbose = event_object_type.objects.get(id=payload[event_type_key]).verbosity - except Exception as e: - verbose=0 - # TODO: cache - - # Convert the datetime for the job event's creation appropriately, - # and include a time zone for it. - # - # In the event of any issue, throw it out, and Django will just save - # the current time. - try: - if not isinstance(payload['created'], datetime.datetime): - payload['created'] = parse_datetime(payload['created']) - if not payload['created'].tzinfo: - payload['created'] = payload['created'].replace(tzinfo=FixedOffset(0)) - except (KeyError, ValueError): - payload.pop('created', None) - - event_uuid = payload.get("uuid", '') - parent_event_uuid = payload.get("parent_uuid", '') - artifact_data = payload.get("artifact_data", None) - - # Sanity check: Don't honor keys that we don't recognize. - for key in payload.keys(): - if key not in (event_type_key, 'event', 'event_data', - 'created', 'counter', 'uuid'): - payload.pop(key) - - try: - # If we're not in verbose mode, wipe out any module - # arguments. - res = payload['event_data'].get('res', {}) - if isinstance(res, dict): - i = res.get('invocation', {}) - if verbose == 0 and 'module_args' in i: - i['module_args'] = '' - - if 'ad_hoc_command_id' in payload: - AdHocCommandEvent.objects.create(**data) - return - - j = JobEvent(**payload) - if payload['event'] == 'playbook_on_start': - j.save() - cache.set("{}_{}".format(payload['job_id'], event_uuid), j.id, 300) - return - else: - if parent_event_uuid: - parent_id = cache.get("{}_{}".format(payload['job_id'], parent_event_uuid), None) - if parent_id is None: - parent_id_obj = JobEvent.objects.filter(uuid=parent_event_uuid, job_id=payload['job_id']) - if parent_id_obj.exists(): # Problematic if not there, means the parent hasn't been written yet... TODO - j.parent_id = parent_id_obj[0].id - print("Settings cache: {}_{} with value {}".format(payload['job_id'], parent_event_uuid, j.parent_id)) - cache.set("{}_{}".format(payload['job_id'], parent_event_uuid), j.parent_id, 300) - else: - print("Cache hit") - j.parent_id = parent_id - j.save(post_process=True) - if event_uuid: - cache.set("{}_{}".format(payload['job_id'], event_uuid), j.id, 300) - except DatabaseError as e: - logger.error("Database Error Saving Job Event: {}".format(e)) - - if artifact_data: - try: - self.process_artifacts(artifact_data, res, payload) - except DatabaseError as e: - logger.error("Database Error Saving Job Artifacts: {}".format(e)) - - def process_artifacts(self, artifact_data, res, payload): - artifact_dict = json.loads(artifact_data) - if res and isinstance(res, dict): - if res.get('_ansible_no_log', False): - artifact_dict['_ansible_no_log'] = True - if artifact_data is not None: - parent_job = Job.objects.filter(pk=payload['job_id']).first() - if parent_job is not None and parent_job.artifacts != artifact_dict: - parent_job.artifacts = artifact_dict - parent_job.save(update_fields=['artifacts']) - class Command(NoArgsCommand): ''' @@ -158,4 +68,3 @@ class Command(NoArgsCommand): worker.run() except KeyboardInterrupt: print('Terminating Callback Receiver') - diff --git a/awx/main/migrations/0044_v310_job_event_stdout.py b/awx/main/migrations/0044_v310_job_event_stdout.py new file mode 100644 index 0000000000..8a66aa9f94 --- /dev/null +++ b/awx/main/migrations/0044_v310_job_event_stdout.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0043_v310_scm_revision'), + ] + + operations = [ + migrations.AddField( + model_name='adhoccommandevent', + name='end_line', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='adhoccommandevent', + name='start_line', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='adhoccommandevent', + name='stdout', + field=models.TextField(default=b'', editable=False), + ), + migrations.AddField( + model_name='adhoccommandevent', + name='uuid', + field=models.CharField(default=b'', max_length=1024, editable=False), + ), + migrations.AddField( + model_name='adhoccommandevent', + name='verbosity', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='jobevent', + name='end_line', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='jobevent', + name='playbook', + field=models.CharField(default=b'', max_length=1024, editable=False), + ), + migrations.AddField( + model_name='jobevent', + name='start_line', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='jobevent', + name='stdout', + field=models.TextField(default=b'', editable=False), + ), + migrations.AddField( + model_name='jobevent', + name='verbosity', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AlterField( + model_name='adhoccommandevent', + name='counter', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AlterField( + model_name='adhoccommandevent', + name='event', + field=models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_unreachable', 'Host Unreachable'), (b'runner_on_skipped', 'Host Skipped'), (b'debug', 'Debug'), (b'verbose', 'Verbose'), (b'deprecated', 'Deprecated'), (b'warning', 'Warning'), (b'system_warning', 'System Warning'), (b'error', 'Error')]), + ), + migrations.AlterField( + model_name='jobevent', + name='counter', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AlterField( + model_name='jobevent', + name='event', + field=models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_error', 'Host Failure'), (b'runner_on_skipped', 'Host Skipped'), (b'runner_on_unreachable', 'Host Unreachable'), (b'runner_on_no_hosts', 'No Hosts Remaining'), (b'runner_on_async_poll', 'Host Polling'), (b'runner_on_async_ok', 'Host Async OK'), (b'runner_on_async_failed', 'Host Async Failure'), (b'runner_on_file_diff', 'File Difference'), (b'playbook_on_start', 'Playbook Started'), (b'playbook_on_notify', 'Running Handlers'), (b'playbook_on_no_hosts_matched', 'No Hosts Matched'), (b'playbook_on_no_hosts_remaining', 'No Hosts Remaining'), (b'playbook_on_task_start', 'Task Started'), (b'playbook_on_vars_prompt', 'Variables Prompted'), (b'playbook_on_setup', 'Gathering Facts'), (b'playbook_on_import_for_host', 'internal: on Import for Host'), (b'playbook_on_not_import_for_host', 'internal: on Not Import for Host'), (b'playbook_on_play_start', 'Play Started'), (b'playbook_on_stats', 'Playbook Complete'), (b'debug', 'Debug'), (b'verbose', 'Verbose'), (b'deprecated', 'Deprecated'), (b'warning', 'Warning'), (b'system_warning', 'System Warning'), (b'error', 'Error')]), + ), + migrations.AlterUniqueTogether( + name='adhoccommandevent', + unique_together=set([]), + ), + migrations.AlterIndexTogether( + name='adhoccommandevent', + index_together=set([('ad_hoc_command', 'event'), ('ad_hoc_command', 'uuid'), ('ad_hoc_command', 'end_line'), ('ad_hoc_command', 'start_line')]), + ), + migrations.AlterIndexTogether( + name='jobevent', + index_together=set([('job', 'event'), ('job', 'parent'), ('job', 'start_line'), ('job', 'uuid'), ('job', 'end_line')]), + ), + ] diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index b03be56452..fc56802160 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +import datetime import hmac import json import logging @@ -9,8 +10,11 @@ from urlparse import urljoin # Django from django.conf import settings +from django.core.cache import cache from django.db import models +from django.utils.dateparse import parse_datetime from django.utils.text import Truncator +from django.utils.timezone import utc from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse @@ -267,14 +271,28 @@ class AdHocCommandEvent(CreatedModifiedModel): #('runner_on_async_failed', _('Host Async Failure'), True), # Tower does not yet support --diff mode #('runner_on_file_diff', _('File Difference'), False), + + # Additional event types for captured stdout not directly related to + # runner events. + ('debug', _('Debug'), False), + ('verbose', _('Verbose'), False), + ('deprecated', _('Deprecated'), False), + ('warning', _('Warning'), False), + ('system_warning', _('System Warning'), False), + ('error', _('Error'), False), ] FAILED_EVENTS = [x[0] for x in EVENT_TYPES if x[2]] EVENT_CHOICES = [(x[0], x[1]) for x in EVENT_TYPES] class Meta: app_label = 'main' - unique_together = [('ad_hoc_command', 'host_name')] ordering = ('-pk',) + index_together = [ + ('ad_hoc_command', 'event'), + ('ad_hoc_command', 'uuid'), + ('ad_hoc_command', 'start_line'), + ('ad_hoc_command', 'end_line'), + ] ad_hoc_command = models.ForeignKey( 'AdHocCommand', @@ -311,8 +329,30 @@ class AdHocCommandEvent(CreatedModifiedModel): default=False, editable=False, ) + uuid = models.CharField( + max_length=1024, + default='', + editable=False, + ) counter = models.PositiveIntegerField( default=0, + editable=False, + ) + stdout = models.TextField( + default='', + editable=False, + ) + verbosity = models.PositiveIntegerField( + default=0, + editable=False, + ) + start_line = models.PositiveIntegerField( + default=0, + editable=False, + ) + end_line = models.PositiveIntegerField( + default=0, + editable=False, ) def get_absolute_url(self): @@ -350,3 +390,28 @@ class AdHocCommandEvent(CreatedModifiedModel): except (IndexError, AttributeError): pass super(AdHocCommandEvent, self).save(*args, **kwargs) + + @classmethod + def create_from_data(self, **kwargs): + # Convert the datetime for the ad hoc command event's creation + # appropriately, and include a time zone for it. + # + # In the event of any issue, throw it out, and Django will just save + # the current time. + try: + if not isinstance(kwargs['created'], datetime.datetime): + kwargs['created'] = parse_datetime(kwargs['created']) + if not kwargs['created'].tzinfo: + kwargs['created'] = kwargs['created'].replace(tzinfo=utc) + except (KeyError, ValueError): + kwargs.pop('created', None) + + # Sanity check: Don't honor keys that we don't recognize. + valid_keys = {'ad_hoc_command_id', 'event', 'event_data', 'created', + 'counter', 'uuid', 'stdout', 'start_line', 'end_line', + 'verbosity'} + for key in kwargs.keys(): + if key not in valid_keys: + kwargs.pop(key) + + return AdHocCommandEvent.objects.create(**kwargs) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 1f11e5dee9..74a8395a2a 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +import datetime import hmac import json import yaml @@ -11,8 +12,12 @@ from urlparse import urljoin # Django from django.conf import settings +from django.core.cache import cache from django.db import models from django.db.models import Q, Count +from django.utils.dateparse import parse_datetime +from django.utils.encoding import force_text +from django.utils.timezone import utc from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse @@ -940,7 +945,7 @@ class JobEvent(CreatedModifiedModel): # - playbook_on_task_start (once for each task within a play) # - runner_on_failed # - runner_on_ok - # - runner_on_error + # - runner_on_error (not used for v2) # - runner_on_skipped # - runner_on_unreachable # - runner_on_no_hosts @@ -962,14 +967,14 @@ class JobEvent(CreatedModifiedModel): (3, 'runner_on_async_poll', _('Host Polling'), False), (3, 'runner_on_async_ok', _('Host Async OK'), False), (3, 'runner_on_async_failed', _('Host Async Failure'), True), - # AWX does not yet support --diff mode + # Tower does not yet support --diff mode (3, 'runner_on_file_diff', _('File Difference'), False), (0, 'playbook_on_start', _('Playbook Started'), False), (2, 'playbook_on_notify', _('Running Handlers'), False), (2, 'playbook_on_no_hosts_matched', _('No Hosts Matched'), False), (2, 'playbook_on_no_hosts_remaining', _('No Hosts Remaining'), False), (2, 'playbook_on_task_start', _('Task Started'), False), - # AWX does not yet support vars_prompt (and will probably hang :) + # Tower does not yet support vars_prompt (and will probably hang :) (1, 'playbook_on_vars_prompt', _('Variables Prompted'), False), (2, 'playbook_on_setup', _('Gathering Facts'), False), # callback will not record this @@ -978,6 +983,15 @@ class JobEvent(CreatedModifiedModel): (2, 'playbook_on_not_import_for_host', _('internal: on Not Import for Host'), False), (1, 'playbook_on_play_start', _('Play Started'), False), (1, 'playbook_on_stats', _('Playbook Complete'), False), + + # Additional event types for captured stdout not directly related to + # playbook or runner events. + (0, 'debug', _('Debug'), False), + (0, 'verbose', _('Verbose'), False), + (0, 'deprecated', _('Deprecated'), False), + (0, 'warning', _('Warning'), False), + (0, 'system_warning', _('System Warning'), False), + (0, 'error', _('Error'), True), ] FAILED_EVENTS = [x[1] for x in EVENT_TYPES if x[3]] EVENT_CHOICES = [(x[1], x[2]) for x in EVENT_TYPES] @@ -986,6 +1000,13 @@ class JobEvent(CreatedModifiedModel): class Meta: app_label = 'main' ordering = ('pk',) + index_together = [ + ('job', 'event'), + ('job', 'uuid'), + ('job', 'start_line'), + ('job', 'end_line'), + ('job', 'parent'), + ] job = models.ForeignKey( 'Job', @@ -1032,12 +1053,17 @@ class JobEvent(CreatedModifiedModel): related_name='job_events', editable=False, ) + playbook = models.CharField( + max_length=1024, + default='', + editable=False, + ) play = models.CharField( max_length=1024, default='', editable=False, ) - role = models.CharField( # FIXME: Determine from callback or task name. + role = models.CharField( max_length=1024, default='', editable=False, @@ -1057,8 +1083,24 @@ class JobEvent(CreatedModifiedModel): ) counter = models.PositiveIntegerField( default=0, + editable=False, + ) + stdout = models.TextField( + default='', + editable=False, + ) + verbosity = models.PositiveIntegerField( + default=0, + editable=False, + ) + start_line = models.PositiveIntegerField( + default=0, + editable=False, + ) + end_line = models.PositiveIntegerField( + default=0, + editable=False, ) - def get_absolute_url(self): return reverse('api:job_event_detail', args=(self.pk,)) @@ -1119,7 +1161,8 @@ class JobEvent(CreatedModifiedModel): pass return msg - def _find_parent(self): + def _find_parent_id(self): + # Find the (most likely) parent event for this event. parent_events = set() if self.event in ('playbook_on_play_start', 'playbook_on_stats', 'playbook_on_vars_prompt'): @@ -1135,101 +1178,55 @@ class JobEvent(CreatedModifiedModel): parent_events.add('playbook_on_setup') parent_events.add('playbook_on_task_start') if parent_events: - try: - qs = JobEvent.objects.filter(job_id=self.job_id) - if self.pk: - qs = qs.filter(pk__lt=self.pk, event__in=parent_events) - else: - qs = qs.filter(event__in=parent_events) - return qs.order_by('-pk')[0] - except IndexError: - pass - return None + qs = JobEvent.objects.filter(job_id=self.job_id, event__in=parent_events).order_by('-pk') + if self.pk: + qs = qs.filter(pk__lt=self.pk) + return qs.only('id').values_list('id', flat=True).first() - def save(self, *args, **kwargs): - from awx.main.models.inventory import Host - # If update_fields has been specified, add our field names to it, - # if it hasn't been specified, then we're just doing a normal save. - update_fields = kwargs.get('update_fields', []) - # Skip normal checks on save if we're only updating failed/changed - # flags triggered from a child event. - from_parent_update = kwargs.pop('from_parent_update', False) - if not from_parent_update: - res = self.event_data.get('res', None) - # Workaround for Ansible 1.2, where the runner_on_async_ok event is - # created even when the async task failed. Change the event to be - # correct. - if self.event == 'runner_on_async_ok': - try: - if res.get('failed', False) or res.get('rc', 0) != 0: - self.event = 'runner_on_async_failed' - except (AttributeError, TypeError): - pass - if self.event in self.FAILED_EVENTS: - if not self.event_data.get('ignore_errors', False): - self.failed = True - if 'failed' not in update_fields: - update_fields.append('failed') - if isinstance(res, dict) and res.get('changed', False): + def _update_from_event_data(self): + # Update job event model fields from event data. + updated_fields = set() + job = self.job + verbosity = job.verbosity + event_data = self.event_data + res = event_data.get('res', None) + if self.event in self.FAILED_EVENTS and not event_data.get('ignore_errors', False): + self.failed = True + updated_fields.add('failed') + if isinstance(res, dict): + if res.get('changed', False): self.changed = True - if 'changed' not in update_fields: - update_fields.append('changed') - if self.event == 'playbook_on_stats': - try: - failures_dict = self.event_data.get('failures', {}) - dark_dict = self.event_data.get('dark', {}) - self.failed = bool(sum(failures_dict.values()) + - sum(dark_dict.values())) - if 'failed' not in update_fields: - update_fields.append('failed') - changed_dict = self.event_data.get('changed', {}) - self.changed = bool(sum(changed_dict.values())) - if 'changed' not in update_fields: - update_fields.append('changed') - except (AttributeError, TypeError): - pass - self.play = self.event_data.get('play', '').strip() - if 'play' not in update_fields: - update_fields.append('play') - self.task = self.event_data.get('task', '').strip() - if 'task' not in update_fields: - update_fields.append('task') - self.role = self.event_data.get('role', '').strip() - if 'role' not in update_fields: - update_fields.append('role') - self.host_name = self.event_data.get('host', '').strip() - if 'host_name' not in update_fields: - update_fields.append('host_name') - # Only update job event hierarchy and related models during post - # processing (after running job). - post_process = kwargs.pop('post_process', False) - if post_process: + updated_fields.add('changed') + # If we're not in verbose mode, wipe out any module arguments. + invocation = res.get('invocation', None) + if isinstance(invocation, dict) and verbosity == 0 and 'module_args' in invocation: + event_data['res']['invocation']['module_args'] = '' + self.event_data = event_data + update_fields.add('event_data') + if self.event == 'playbook_on_stats': try: - if not self.host_id and self.host_name: - host_qs = Host.objects.filter(inventory__jobs__id=self.job_id, name=self.host_name) - host_id = host_qs.only('id').values_list('id', flat=True) - if host_id.exists(): - self.host_id = host_id[0] - if 'host_id' not in update_fields: - update_fields.append('host_id') - except (IndexError, AttributeError): + failures_dict = event_data.get('failures', {}) + dark_dict = event_data.get('dark', {}) + self.failed = bool(sum(failures_dict.values()) + + sum(dark_dict.values())) + updated_fields.add('failed') + changed_dict = event_data.get('changed', {}) + self.changed = bool(sum(changed_dict.values())) + updated_fields.add('changed') + except (AttributeError, TypeError): pass - if self.parent is None: - self.parent = self._find_parent() - if 'parent' not in update_fields: - update_fields.append('parent') - super(JobEvent, self).save(*args, **kwargs) - if post_process and not from_parent_update: - self.update_parent_failed_and_changed() - # FIXME: The update_hosts() call (and its queries) are the current - # performance bottleneck.... - if getattr(settings, 'CAPTURE_JOB_EVENT_HOSTS', False): - self.update_hosts() - self.update_host_summary_from_stats() + for field in ('playbook', 'play', 'task', 'role', 'host'): + value = force_text(event_data.get(field, '')).strip() + if field == 'host': + field = 'host_name' + if value != getattr(self, field): + setattr(self, field, value) + updated_fields.add(field) + return updated_fields - def update_parent_failed_and_changed(self): - # Propagage failed and changed flags to parent events. - if self.parent: + def _update_parent_failed_and_changed(self): + # Propagate failed and changed flags to parent events. + if self.parent_id: parent = self.parent update_fields = [] if self.failed and not parent.failed: @@ -1240,9 +1237,10 @@ class JobEvent(CreatedModifiedModel): update_fields.append('changed') if update_fields: parent.save(update_fields=update_fields, from_parent_update=True) - parent.update_parent_failed_and_changed() + parent._update_parent_failed_and_changed() - def update_hosts(self, extra_host_pks=None): + def _update_hosts(self, extra_host_pks=None): + # Update job event hosts m2m from host_name, propagate to parent events. from awx.main.models.inventory import Host extra_host_pks = set(extra_host_pks or []) hostnames = set() @@ -1256,16 +1254,14 @@ class JobEvent(CreatedModifiedModel): pass qs = Host.objects.filter(inventory__jobs__id=self.job_id) qs = qs.filter(Q(name__in=hostnames) | Q(pk__in=extra_host_pks)) - qs = qs.exclude(job_events__pk=self.id) - for host in qs.only('id'): + qs = qs.exclude(job_events__pk=self.id).only('id') + for host in qs: self.hosts.add(host) - if self.parent: - self.parent.update_hosts(self.hosts.only('id').values_list('id', flat=True)) + if self.parent_id: + self.parent._update_hosts(qs.values_list('id', flat=True)) - def update_host_summary_from_stats(self): + def _update_host_summary_from_stats(self): from awx.main.models.inventory import Host - if self.event != 'playbook_on_stats': - return hostnames = set() try: for v in self.event_data.values(): @@ -1276,7 +1272,6 @@ class JobEvent(CreatedModifiedModel): qs = Host.objects.filter(inventory__jobs__id=self.job_id, name__in=hostnames) job = self.job - #for host in qs.only('id', 'name'): for host in hostnames: host_stats = {} for stat in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'): @@ -1300,6 +1295,112 @@ class JobEvent(CreatedModifiedModel): job.inventory.update_computed_fields() emit_channel_notification('jobs-summary', dict(group_name='jobs', unified_job_id=job.id)) + def save(self, *args, **kwargs): + from awx.main.models.inventory import Host + # If update_fields has been specified, add our field names to it, + # if it hasn't been specified, then we're just doing a normal save. + update_fields = kwargs.get('update_fields', []) + # Update model fields and related objects unless we're only updating + # failed/changed flags triggered from a child event. + from_parent_update = kwargs.pop('from_parent_update', False) + if not from_parent_update: + # Update model fields from event data. + updated_fields = self._update_from_event_data() + for field in updated_fields: + if field not in update_fields: + update_fields.append(field) + # Update host related field from host_name. + if not self.host_id and self.host_name: + host_qs = Host.objects.filter(inventory__jobs__id=self.job_id, name=self.host_name) + host_id = host_qs.only('id').values_list('id', flat=True).first() + if host_id != self.host_id: + self.host_id = host_id + if 'host_id' not in update_fields: + update_fields.append('host_id') + # Update parent related field if not set. + if self.parent_id is None: + self.parent_id = self._find_parent_id() + if self.parent_id and 'parent_id' not in update_fields: + update_fields.append('parent_id') + super(JobEvent, self).save(*args, **kwargs) + # Update related objects after this event is saved. + if not from_parent_update: + if self.parent_id: + self._update_parent_failed_and_changed() + # FIXME: The update_hosts() call (and its queries) are the current + # performance bottleneck.... + if getattr(settings, 'CAPTURE_JOB_EVENT_HOSTS', False): + self._update_hosts() + if self.event == 'playbook_on_stats': + self._update_host_summary_from_stats() + + @classmethod + def create_from_data(self, **kwargs): + # Must have a job_id specified. + if not kwargs.get('job_id', None): + return + + # Convert the datetime for the job event's creation appropriately, + # and include a time zone for it. + # + # In the event of any issue, throw it out, and Django will just save + # the current time. + try: + if not isinstance(kwargs['created'], datetime.datetime): + kwargs['created'] = parse_datetime(kwargs['created']) + if not kwargs['created'].tzinfo: + kwargs['created'] = kwargs['created'].replace(tzinfo=utc) + except (KeyError, ValueError): + kwargs.pop('created', None) + + # Save UUID and parent UUID for determining parent-child relationship. + job_event_uuid = kwargs.get('uuid', None) + parent_event_uuid = kwargs.get('parent_uuid', None) + artifact_data = kwargs.get('artifact_data', None) + + # Sanity check: Don't honor keys that we don't recognize. + valid_keys = {'job_id', 'event', 'event_data', 'playbook', 'play', + 'role', 'task', 'created', 'counter', 'uuid', 'stdout', + 'start_line', 'end_line', 'verbosity'} + for key in kwargs.keys(): + if key not in valid_keys: + kwargs.pop(key) + + # Try to find a parent event based on UUID. + if parent_event_uuid: + cache_key = '{}_{}'.format(kwargs['job_id'], parent_event_uuid) + parent_id = cache.get(cache_key) + if parent_id is None: + parent_id = JobEvent.objects.filter(job_id=kwargs['job_id'], uuid=parent_event_uuid).only('id').values_list('id', flat=True).first() + if parent_id: + print("Settings cache: {} with value {}".format(cache_key, parent_id)) + cache.set(cache_key, parent_id, 300) + if parent_id: + kwargs['parent_id'] = parent_id + + job_event = JobEvent.objects.create(**kwargs) + + # Cache this job event ID vs. UUID for future parent lookups. + if job_event_uuid: + cache_key = '{}_{}'.format(kwargs['job_id'], job_event_uuid) + cache.set(cache_key, job_event.id, 300) + + # Save artifact data to parent job (if provided). + if artifact_data: + artifact_dict = json.loads(artifact_data) + event_data = kwargs.get('event_data', None) + if event_data and isinstance(event_data, dict): + res = event_data.get('res', None) + if res and isinstance(res, dict): + if res.get('_ansible_no_log', False): + artifact_dict['_ansible_no_log'] = True + parent_job = Job.objects.filter(pk=kwargs['job_id']).first() + if parent_job and parent_job.artifacts != artifact_dict: + parent_job.artifacts = artifact_dict + parent_job.save(update_fields=['artifacts']) + + return job_event + @classmethod def get_startevent_queryset(cls, parent_task, starting_events, ordering=None): ''' diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 677be8136d..674bedbffe 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -696,8 +696,11 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique return StringIO(msg['missing' if self.finished else 'pending']) def _escape_ascii(self, content): - ansi_escape = re.compile(r'\x1b[^m]*m') - return ansi_escape.sub('', content) + # Remove ANSI escape sequences used to embed event data. + content = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', content) + # Remove ANSI color escape sequences. + content = re.sub(r'\x1b[^m]*m', '', content) + return content def _result_stdout_raw(self, redact_sensitive=False, escape_ascii=False): content = self.result_stdout_raw_handle().read() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index dcb2bc5258..503509890b 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -49,7 +49,8 @@ from awx.main.models import * # noqa from awx.main.models import UnifiedJob from awx.main.task_engine import TaskEnhancer from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, - check_proot_installed, build_proot_temp_dir, wrap_args_with_proot) + check_proot_installed, build_proot_temp_dir, wrap_args_with_proot, + OutputEventFilter) from awx.main.consumers import emit_channel_notification __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', @@ -397,6 +398,8 @@ class BaseTask(Task): if os.path.isdir(os.path.join(venv_libdir, python_ver)): env['PYTHONPATH'] = os.path.join(venv_libdir, python_ver, "site-packages") + ":" break + # Add awx/lib to PYTHONPATH. + env['PYTHONPATH'] = ':'.join(filter(None, [self.get_path_to('..', 'lib'), env.get('PYTHONPATH', '')])) return env def add_tower_venv(self, env): @@ -494,6 +497,17 @@ class BaseTask(Task): ''' return OrderedDict() + def get_stdout_handle(self, instance): + ''' + Return an open file object for capturing stdout. + ''' + if not os.path.exists(settings.JOBOUTPUT_ROOT): + os.makedirs(settings.JOBOUTPUT_ROOT) + stdout_filename = os.path.join(settings.JOBOUTPUT_ROOT, "%d-%s.out" % (instance.pk, str(uuid.uuid1()))) + stdout_handle = codecs.open(stdout_filename, 'w', encoding='utf-8') + assert stdout_handle.name == stdout_filename + return stdout_handle + def run_pexpect(self, instance, args, cwd, env, passwords, stdout_handle, output_replacements=None, extra_update_fields=None): ''' @@ -643,10 +657,7 @@ class BaseTask(Task): cwd = self.build_cwd(instance, **kwargs) env = self.build_env(instance, **kwargs) safe_env = self.build_safe_env(instance, **kwargs) - if not os.path.exists(settings.JOBOUTPUT_ROOT): - os.makedirs(settings.JOBOUTPUT_ROOT) - stdout_filename = os.path.join(settings.JOBOUTPUT_ROOT, "%d-%s.out" % (pk, str(uuid.uuid1()))) - stdout_handle = codecs.open(stdout_filename, 'w', encoding='utf-8') + stdout_handle = self.get_stdout_handle(instance) if self.should_use_proot(instance, **kwargs): if not check_proot_installed(): raise RuntimeError('proot is not installed') @@ -660,7 +671,7 @@ class BaseTask(Task): args = self.wrap_args_with_ssh_agent(args, ssh_key_path, ssh_auth_sock) safe_args = self.wrap_args_with_ssh_agent(safe_args, ssh_key_path, ssh_auth_sock) instance = self.update_model(pk, job_args=json.dumps(safe_args), - job_cwd=cwd, job_env=safe_env, result_stdout_file=stdout_filename) + job_cwd=cwd, job_env=safe_env, result_stdout_file=stdout_handle.name) status, rc = self.run_pexpect(instance, args, cwd, env, kwargs['passwords'], stdout_handle, extra_update_fields=extra_update_fields) except Exception: @@ -779,6 +790,7 @@ class RunJob(BaseTask): if job.project: env['PROJECT_REVISION'] = job.project.scm_revision env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_path + env['ANSIBLE_STDOUT_CALLBACK'] = 'tower_display' env['REST_API_URL'] = settings.INTERNAL_API_URL env['REST_API_TOKEN'] = job.task_auth_token or '' env['CALLBACK_QUEUE'] = settings.CALLBACK_QUEUE @@ -974,6 +986,16 @@ class RunJob(BaseTask): d[re.compile(r'^Vault password:\s*?$', re.M)] = 'vault_password' return d + def get_stdout_handle(self, instance): + ''' + Wrap stdout file object to capture events. + ''' + stdout_handle = super(RunJob, self).get_stdout_handle(instance) + def job_event_callback(event_data): + event_data.setdefault('job_id', instance.id) + JobEvent.create_from_data(**event_data) + return OutputEventFilter(stdout_handle, job_event_callback) + def get_ssh_key_path(self, instance, **kwargs): ''' If using an SSH key, return the path for use by ssh-agent. @@ -1019,11 +1041,6 @@ class RunJob(BaseTask): pass else: update_inventory_computed_fields.delay(inventory.id, True) - # Update job event fields after job has completed (only when using REST - # API callback). - if not getattr(settings, 'CALLBACK_CONSUMER_PORT', None) and not getattr(settings, 'CALLBACK_QUEUE', None): - for job_event in job.job_events.order_by('pk'): - job_event.save(post_process=True) class RunProjectUpdate(BaseTask): @@ -1597,6 +1614,7 @@ class RunAdHocCommand(BaseTask): env['INVENTORY_HOSTVARS'] = str(True) env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_dir env['ANSIBLE_LOAD_CALLBACK_PLUGINS'] = '1' + env['ANSIBLE_STDOUT_CALLBACK'] = 'minimal' # Hardcoded by Ansible for ad-hoc commands (either minimal or oneline). env['REST_API_URL'] = settings.INTERNAL_API_URL env['REST_API_TOKEN'] = ad_hoc_command.task_auth_token or '' env['CALLBACK_QUEUE'] = settings.CALLBACK_QUEUE @@ -1693,6 +1711,16 @@ class RunAdHocCommand(BaseTask): d[re.compile(r'^Password:\s*?$', re.M)] = 'ssh_password' return d + def get_stdout_handle(self, instance): + ''' + Wrap stdout file object to capture events. + ''' + stdout_handle = super(RunAdHocCommand, self).get_stdout_handle(instance) + def ad_hoc_command_event_callback(event_data): + event_data.setdefault('ad_hoc_command_id', instance.id) + AdHocCommandEvent.create_from_data(**event_data) + return OutputEventFilter(stdout_handle, ad_hoc_command_event_callback) + def get_ssh_key_path(self, instance, **kwargs): ''' If using an SSH key, return the path for use by ssh-agent. diff --git a/awx/main/utils.py b/awx/main/utils.py index c652f8e166..76f28f090a 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -4,6 +4,7 @@ # Python import base64 import hashlib +import json import logging import os import re @@ -36,7 +37,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'get_type_for_model', 'get_model_for_type', 'cache_list_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided', - 'get_current_apps', 'set_current_apps'] + 'get_current_apps', 'set_current_apps', 'OutputEventFilter'] def get_object_or_400(klass, *args, **kwargs): @@ -640,3 +641,71 @@ def set_current_apps(apps): def get_current_apps(): global current_apps return current_apps + + +class OutputEventFilter(object): + ''' + File-like object that looks for encoded job events in stdout data. + ''' + + EVENT_DATA_RE = re.compile(r'\x1b\[K((?:[A-Za-z0-9+/=]+\x1b\[\d+D)+)\x1b\[K') + + def __init__(self, fileobj=None, event_callback=None): + self._fileobj = fileobj + self._event_callback = event_callback + self._counter = 1 + self._start_line = 0 + self._buffer = '' + self._current_event_data = None + + def __getattr__(self, attr): + return getattr(self._fileobj, attr) + + def write(self, data): + if self._fileobj: + self._fileobj.write(data) + self._buffer += data + while True: + match = self.EVENT_DATA_RE.search(self._buffer) + if not match: + break + try: + base64_data = re.sub(r'\x1b\[\d+D', '', match.group(1)) + event_data = json.loads(base64.b64decode(base64_data)) + except ValueError: + event_data = {} + self._emit_event(self._buffer[:match.start()], event_data) + self._buffer = self._buffer[match.end():] + + def close(self): + if self._fileobj: + self._fileobj.close() + if self._buffer: + self._emit_event(self._buffer) + self._buffer = '' + + def _emit_event(self, buffered_stdout, next_event_data=None): + if self._current_event_data: + event_data = self._current_event_data + stdout_chunks = [buffered_stdout] + elif buffered_stdout: + event_data = dict(event='verbose') + stdout_chunks = buffered_stdout.splitlines(True) + else: + stdout_chunks = [] + + for stdout_chunk in stdout_chunks: + event_data['counter'] = self._counter + self._counter += 1 + event_data['stdout'] = stdout_chunk + n_lines = stdout_chunk.count('\n') + event_data['start_line'] = self._start_line + event_data['end_line'] = self._start_line + n_lines + self._start_line += n_lines + if self._event_callback: + self._event_callback(event_data) + + if next_event_data.get('uuid', None): + self._current_event_data = next_event_data + else: + self._current_event_data = None diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py deleted file mode 100644 index 67f36612f6..0000000000 --- a/awx/plugins/callback/job_event_callback.py +++ /dev/null @@ -1,579 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# This file is a utility Ansible plugin that is not part of the AWX or Ansible -# packages. It does not import any code from either package, nor does its -# license apply to Ansible or AWX. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions are met: -# -# Redistributions of source code must retain the above copyright notice, this -# list of conditions and the following disclaimer. -# -# Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# Neither the name of the nor the names of its contributors -# may be used to endorse or promote products derived from this software -# without specific prior written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. - -# Python -import datetime -import glob -import json -import logging -import os -import pwd -import urlparse -import re -from copy import deepcopy -from uuid import uuid4 - -# Kombu -from kombu import Connection, Exchange, Producer - -# Requests -import requests - -import psutil - -CENSOR_FIELD_WHITELIST = [ - 'msg', - 'failed', - 'changed', - 'results', - 'start', - 'end', - 'delta', - 'cmd', - '_ansible_no_log', - 'rc', - 'failed_when_result', - 'skipped', - 'skip_reason', -] - -def censor(obj, no_log=False): - if not isinstance(obj, dict): - if no_log: - return "the output has been hidden due to the fact that 'no_log: true' was specified for this result" - return obj - if obj.get('_ansible_no_log', no_log): - new_obj = {} - for k in CENSOR_FIELD_WHITELIST: - if k in obj: - new_obj[k] = obj[k] - if k == 'cmd' and k in obj: - if isinstance(obj['cmd'], list): - obj['cmd'] = ' '.join(obj['cmd']) - if re.search(r'\s', obj['cmd']): - new_obj['cmd'] = re.sub(r'^(([^\s\\]|\\\s)+).*$', - r'\1 ', - obj['cmd']) - new_obj['censored'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" - obj = new_obj - if 'results' in obj: - if isinstance(obj['results'], list): - for i in xrange(len(obj['results'])): - obj['results'][i] = censor(obj['results'][i], obj.get('_ansible_no_log', no_log)) - elif obj.get('_ansible_no_log', False): - obj['results'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" - return obj - - -class TokenAuth(requests.auth.AuthBase): - - def __init__(self, token): - self.token = token - - def __call__(self, request): - request.headers['Authorization'] = 'Token %s' % self.token - return request - - -# TODO: non v2_ events are deprecated and should be purge/refactored out -class BaseCallbackModule(object): - ''' - Callback module for logging ansible-playbook job events via the REST API. - ''' - - def __init__(self): - self.base_url = os.getenv('REST_API_URL', '') - self.auth_token = os.getenv('REST_API_TOKEN', '') - self.callback_connection = os.getenv('CALLBACK_CONNECTION', None) - self.connection_queue = os.getenv('CALLBACK_QUEUE', '') - self.connection = None - self.exchange = None - self._init_logging() - self._init_connection() - self.counter = 0 - self.active_playbook = None - self.active_play = None - self.active_task = None - - def _init_logging(self): - try: - self.job_callback_debug = int(os.getenv('JOB_CALLBACK_DEBUG', '0')) - except ValueError: - self.job_callback_debug = 0 - self.logger = logging.getLogger('awx.plugins.callback.job_event_callback') - if self.job_callback_debug >= 2: - self.logger.setLevel(logging.DEBUG) - elif self.job_callback_debug >= 1: - self.logger.setLevel(logging.INFO) - else: - self.logger.setLevel(logging.WARNING) - handler = logging.StreamHandler() - formatter = logging.Formatter('%(levelname)-8s %(process)-8d %(message)s') - handler.setFormatter(formatter) - self.logger.addHandler(handler) - self.logger.propagate = False - - def _init_connection(self): - self.connection = None - - def _start_connection(self): - self.connection = Connection(self.callback_connection) - self.exchange = Exchange(self.connection_queue, type='direct') - - def _post_job_event_queue_msg(self, event, event_data): - self.counter += 1 - msg = { - 'event': event, - 'event_data': event_data, - 'counter': self.counter, - 'created': datetime.datetime.utcnow().isoformat(), - } - if event in ('playbook_on_play_start', - 'playbook_on_stats', - 'playbook_on_vars_prompt'): - msg['parent_uuid'] = str(self.active_playbook) - elif event in ('playbook_on_notify', - 'playbook_on_setup', - 'playbook_on_task_start', - 'playbook_on_no_hosts_matched', - 'playbook_on_no_hosts_remaining', - 'playbook_on_include', - 'playbook_on_import_for_host', - 'playbook_on_not_import_for_host'): - msg['parent_uuid'] = str(self.active_play) - elif event.startswith('runner_on_') or event.startswith('runner_item_on_'): - msg['parent_uuid'] = str(self.active_task) - else: - msg['parent_uuid'] = '' - - if "uuid" in event_data: - msg['uuid'] = str(event_data['uuid']) - else: - msg['uuid'] = '' - - if getattr(self, 'job_id', None): - msg['job_id'] = self.job_id - if getattr(self, 'ad_hoc_command_id', None): - msg['ad_hoc_command_id'] = self.ad_hoc_command_id - - if getattr(self, 'artifact_data', None): - msg['artifact_data'] = self.artifact_data - - active_pid = os.getpid() - if self.job_callback_debug: - msg.update({ - 'pid': active_pid, - }) - for retry_count in xrange(4): - try: - if not hasattr(self, 'connection_pid'): - self.connection_pid = active_pid - if self.connection_pid != active_pid: - self._init_connection() - if self.connection is None: - self._start_connection() - - producer = Producer(self.connection) - producer.publish(msg, - serializer='json', - compression='bzip2', - exchange=self.exchange, - declare=[self.exchange], - routing_key=self.connection_queue) - return - except Exception, e: - self.logger.info('Publish Job Event Exception: %r, retry=%d', e, - retry_count, exc_info=True) - retry_count += 1 - if retry_count >= 3: - break - - def _post_rest_api_event(self, event, event_data): - data = json.dumps({ - 'event': event, - 'event_data': event_data, - }) - parts = urlparse.urlsplit(self.base_url) - if parts.username and parts.password: - auth = (parts.username, parts.password) - elif self.auth_token: - auth = TokenAuth(self.auth_token) - else: - auth = None - port = parts.port or (443 if parts.scheme == 'https' else 80) - url = urlparse.urlunsplit([parts.scheme, - '%s:%d' % (parts.hostname, port), - parts.path, parts.query, parts.fragment]) - url = urlparse.urljoin(url, self.rest_api_path) - headers = {'content-type': 'application/json'} - response = requests.post(url, data=data, headers=headers, auth=auth) - response.raise_for_status() - - def _log_event(self, event, **event_data): - if 'res' in event_data: - event_data['res'] = censor(deepcopy(event_data['res'])) - - if self.callback_connection: - self._post_job_event_queue_msg(event, event_data) - else: - self._post_rest_api_event(event, event_data) - - def on_any(self, *args, **kwargs): - pass - - def runner_on_failed(self, host, res, ignore_errors=False): - self._log_event('runner_on_failed', host=host, res=res, - ignore_errors=ignore_errors) - - def v2_runner_on_failed(self, result, ignore_errors=False): - event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None - self._log_event('runner_on_failed', host=result._host.name, - res=result._result, task=result._task, - ignore_errors=ignore_errors, event_loop=event_is_loop) - - def runner_on_ok(self, host, res): - self._log_event('runner_on_ok', host=host, res=res) - - def v2_runner_on_ok(self, result): - event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None - self._log_event('runner_on_ok', host=result._host.name, - task=result._task, res=result._result, - event_loop=event_is_loop) - - def runner_on_error(self, host, msg): - self._log_event('runner_on_error', host=host, msg=msg) - - def v2_runner_on_error(self, result): - pass # Currently not implemented in v2 - - def runner_on_skipped(self, host, item=None): - self._log_event('runner_on_skipped', host=host, item=item) - - def v2_runner_on_skipped(self, result): - event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None - self._log_event('runner_on_skipped', host=result._host.name, - task=result._task, event_loop=event_is_loop) - - def runner_on_unreachable(self, host, res): - self._log_event('runner_on_unreachable', host=host, res=res) - - def v2_runner_on_unreachable(self, result): - self._log_event('runner_on_unreachable', host=result._host.name, - task=result._task, res=result._result) - - def runner_on_no_hosts(self): - self._log_event('runner_on_no_hosts') - - def v2_runner_on_no_hosts(self, task): - self._log_event('runner_on_no_hosts', task=task) - - # V2 does not use the _on_async callbacks (yet). - - def runner_on_async_poll(self, host, res, jid, clock): - self._log_event('runner_on_async_poll', host=host, res=res, jid=jid, - clock=clock) - - def runner_on_async_ok(self, host, res, jid): - self._log_event('runner_on_async_ok', host=host, res=res, jid=jid) - - def runner_on_async_failed(self, host, res, jid): - self._log_event('runner_on_async_failed', host=host, res=res, jid=jid) - - def runner_on_file_diff(self, host, diff): - self._log_event('runner_on_file_diff', host=host, diff=diff) - - def v2_runner_on_file_diff(self, result, diff): - self._log_event('runner_on_file_diff', host=result._host.name, - task=result._task, diff=diff) - - def v2_runner_item_on_ok(self, result): - self._log_event('runner_item_on_ok', res=result._result, host=result._host.name, - task=result._task) - - def v2_runner_item_on_failed(self, result): - self._log_event('runner_item_on_failed', res=result._result, host=result._host.name, - task=result._task) - - def v2_runner_item_on_skipped(self, result): - self._log_event('runner_item_on_skipped', res=result._result, host=result._host.name, - task=result._task) - - @staticmethod - def terminate_ssh_control_masters(): - # Determine if control persist is being used and if any open sockets - # exist after running the playbook. - cp_path = os.environ.get('ANSIBLE_SSH_CONTROL_PATH', '') - if not cp_path: - return - cp_dir = os.path.dirname(cp_path) - if not os.path.exists(cp_dir): - return - cp_pattern = os.path.join(cp_dir, 'ansible-ssh-*') - cp_files = glob.glob(cp_pattern) - if not cp_files: - return - - # Attempt to find any running control master processes. - username = pwd.getpwuid(os.getuid())[0] - ssh_cm_procs = [] - for proc in psutil.process_iter(): - try: - pname = proc.name() - pcmdline = proc.cmdline() - pusername = proc.username() - except psutil.NoSuchProcess: - continue - if pusername != username: - continue - if pname != 'ssh': - continue - for cp_file in cp_files: - if pcmdline and cp_file in pcmdline[0]: - ssh_cm_procs.append(proc) - break - - # Terminate then kill control master processes. Workaround older - # version of psutil that may not have wait_procs implemented. - for proc in ssh_cm_procs: - proc.terminate() - procs_gone, procs_alive = psutil.wait_procs(ssh_cm_procs, timeout=5) - for proc in procs_alive: - proc.kill() - - -class JobCallbackModule(BaseCallbackModule): - ''' - Callback module for logging ansible-playbook job events via the REST API. - ''' - - # These events should never have an associated play. - EVENTS_WITHOUT_PLAY = [ - 'playbook_on_start', - 'playbook_on_stats', - ] - # These events should never have an associated task. - EVENTS_WITHOUT_TASK = EVENTS_WITHOUT_PLAY + [ - 'playbook_on_setup', - 'playbook_on_notify', - 'playbook_on_import_for_host', - 'playbook_on_not_import_for_host', - 'playbook_on_no_hosts_matched', - 'playbook_on_no_hosts_remaining', - ] - - def __init__(self): - self.job_id = int(os.getenv('JOB_ID', '0')) - self.rest_api_path = '/api/v1/jobs/%d/job_events/' % self.job_id - super(JobCallbackModule, self).__init__() - - def _log_event(self, event, **event_data): - play = getattr(self, 'play', None) - play_name = getattr(play, 'name', '') - if play_name and event not in self.EVENTS_WITHOUT_PLAY: - event_data['play'] = play_name - task = event_data.pop('task', None) or getattr(self, 'task', None) - task_name = None - role_name = None - if task: - if hasattr(task, 'get_name'): - # in v2, the get_name() method creates the name - task_name = task.get_name() - else: - # v1 datastructure - task_name = getattr(task, 'name', '') - if hasattr(task, '_role') and task._role: - # v2 datastructure - role_name = task._role._role_name - else: - # v1 datastructure - role_name = getattr(task, 'role_name', '') - if task_name and event not in self.EVENTS_WITHOUT_TASK: - event_data['task'] = task_name - if role_name and event not in self.EVENTS_WITHOUT_TASK: - event_data['role'] = role_name - self.artifact_data = None - if 'res' in event_data and 'artifact_data' in event_data['res']: - self.artifact_data = event_data['res']['artifact_data'] - super(JobCallbackModule, self)._log_event(event, **event_data) - - def playbook_on_start(self): - self._log_event('playbook_on_start') - - def v2_playbook_on_start(self, playbook): - # NOTE: the playbook parameter was added late in Ansible 2.0 development - # so we don't currently utilize but could later. - # NOTE: Ansible doesn't generate a UUID for playbook_on_start so we'll do it for them - self.active_playbook = str(uuid4()) - self._log_event('playbook_on_start', uuid=self.active_playbook) - - def playbook_on_notify(self, host, handler): - self._log_event('playbook_on_notify', host=host, handler=handler) - - def v2_playbook_on_notify(self, result, handler): - self._log_event('playbook_on_notify', host=result._host.name, - task=result._task, handler=handler) - - def playbook_on_no_hosts_matched(self): - self._log_event('playbook_on_no_hosts_matched') - - def v2_playbook_on_no_hosts_matched(self): - # since there is no task/play info, this is currently identical - # to the v1 callback which does the same thing - self.playbook_on_no_hosts_matched() - - def playbook_on_no_hosts_remaining(self): - self._log_event('playbook_on_no_hosts_remaining') - - def v2_playbook_on_no_hosts_remaining(self): - # since there is no task/play info, this is currently identical - # to the v1 callback which does the same thing - self.playbook_on_no_hosts_remaining() - - def playbook_on_task_start(self, name, is_conditional): - self._log_event('playbook_on_task_start', name=name, - is_conditional=is_conditional) - - def v2_playbook_on_task_start(self, task, is_conditional): - self.active_task = task._uuid - self._log_event('playbook_on_task_start', task=task, uuid=str(task._uuid), - name=task.get_name(), is_conditional=is_conditional) - - def v2_playbook_on_cleanup_task_start(self, task): - # re-using playbook_on_task_start event here for this v2-specific - # event, though we may consider any changes necessary to distinguish - # this from a normal task - self.active_task = task._uuid - self._log_event('playbook_on_task_start', task=task, uuid=str(task._uuid), - name=task.get_name()) - - def playbook_on_vars_prompt(self, varname, private=True, prompt=None, - encrypt=None, confirm=False, salt_size=None, - salt=None, default=None): - self._log_event('playbook_on_vars_prompt', varname=varname, - private=private, prompt=prompt, encrypt=encrypt, - confirm=confirm, salt_size=salt_size, salt=salt, - default=default) - - def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, - encrypt=None, confirm=False, salt_size=None, - salt=None, default=None): - pass # not currently used in v2 (yet) - - def playbook_on_setup(self): - self._log_event('playbook_on_setup') - - def v2_playbook_on_setup(self): - pass # not currently used in v2 (yet) - - def playbook_on_import_for_host(self, host, imported_file): - # don't care about recording this one - # self._log_event('playbook_on_import_for_host', host=host, - # imported_file=imported_file) - pass - - def v2_playbook_on_import_for_host(self, result, imported_file): - pass # not currently used in v2 (yet) - - def playbook_on_not_import_for_host(self, host, missing_file): - # don't care about recording this one - #self._log_event('playbook_on_not_import_for_host', host=host, - # missing_file=missing_file) - pass - - def v2_playbook_on_not_import_for_host(self, result, missing_file): - pass # not currently used in v2 (yet) - - def playbook_on_play_start(self, name): - # Only play name is passed via callback, get host pattern from the play. - pattern = getattr(getattr(self, 'play', None), 'hosts', name) - self._log_event('playbook_on_play_start', name=name, pattern=pattern) - - def v2_playbook_on_play_start(self, play): - setattr(self, 'play', play) - # Ansible 2.0.0.2 doesn't default .name to hosts like it did in 1.9.4, - # though that default will likely return in a future version of Ansible. - if (not hasattr(play, 'name') or not play.name) and hasattr(play, 'hosts'): - if isinstance(play.hosts, list): - play.name = ','.join(play.hosts) - else: - play.name = play.hosts - self.active_play = play._uuid - self._log_event('playbook_on_play_start', name=play.name, uuid=str(play._uuid), - pattern=play.hosts) - - def playbook_on_stats(self, stats): - d = {} - for attr in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'): - d[attr] = getattr(stats, attr) - self._log_event('playbook_on_stats', **d) - self.terminate_ssh_control_masters() - - def v2_playbook_on_stats(self, stats): - self.playbook_on_stats(stats) - - def v2_playbook_on_include(self, included_file): - self._log_event('playbook_on_include', included_file=included_file) - -class AdHocCommandCallbackModule(BaseCallbackModule): - ''' - Callback module for logging ansible ad hoc events via ZMQ or the REST API. - ''' - - def __init__(self): - self.ad_hoc_command_id = int(os.getenv('AD_HOC_COMMAND_ID', '0')) - self.rest_api_path = '/api/v1/ad_hoc_commands/%d/events/' % self.ad_hoc_command_id - self.skipped_hosts = set() - super(AdHocCommandCallbackModule, self).__init__() - - def _log_event(self, event, **event_data): - # Ignore task for ad hoc commands (with v2). - event_data.pop('task', None) - super(AdHocCommandCallbackModule, self)._log_event(event, **event_data) - - def runner_on_file_diff(self, host, diff): - pass # Ignore file diff for ad hoc commands. - - def runner_on_ok(self, host, res): - # When running in check mode using a module that does not support check - # mode, Ansible v1.9 will call runner_on_skipped followed by - # runner_on_ok for the same host; only capture the skipped event and - # ignore the ok event. - if host not in self.skipped_hosts: - super(AdHocCommandCallbackModule, self).runner_on_ok(host, res) - - def runner_on_skipped(self, host, item=None): - super(AdHocCommandCallbackModule, self).runner_on_skipped(host, item) - self.skipped_hosts.add(host) - -if os.getenv('JOB_ID', ''): - CallbackModule = JobCallbackModule -elif os.getenv('AD_HOC_COMMAND_ID', ''): - CallbackModule = AdHocCommandCallbackModule diff --git a/awx/plugins/callback/minimal.py b/awx/plugins/callback/minimal.py new file mode 100644 index 0000000000..e34ebdd78a --- /dev/null +++ b/awx/plugins/callback/minimal.py @@ -0,0 +1,30 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import os +import sys + +# Add awx/lib to sys.path. +awx_lib_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'lib')) +if awx_lib_path not in sys.path: + sys.path.insert(0, awx_lib_path) + +# Tower Display Callback +from tower_display_callback import TowerMinimalCallbackModule as CallbackModule diff --git a/awx/plugins/callback/tower_display.py b/awx/plugins/callback/tower_display.py new file mode 100644 index 0000000000..36f6a29537 --- /dev/null +++ b/awx/plugins/callback/tower_display.py @@ -0,0 +1,30 @@ +# Copyright (c) 2016 Ansible by Red Hat, Inc. +# +# This file is part of Ansible Tower, but depends on code imported from Ansible. +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +from __future__ import (absolute_import, division, print_function) + +# Python +import os +import sys + +# Add awx/lib to sys.path. +awx_lib_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'lib')) +if awx_lib_path not in sys.path: + sys.path.insert(0, awx_lib_path) + +# Tower Display Callback +from tower_display_callback import TowerDefaultCallbackModule as CallbackModule From 440f0539b08d1b52c27cf2cd0c9f162d0a196e4a Mon Sep 17 00:00:00 2001 From: Chris Church Date: Sat, 22 Oct 2016 00:26:06 -0400 Subject: [PATCH 10/14] Flake8 fixes. --- awx/api/views.py | 3 +-- awx/lib/sitecustomize.py | 2 +- awx/lib/tower_display_callback/__init__.py | 4 ++-- awx/lib/tower_display_callback/cleanup.py | 2 +- awx/lib/tower_display_callback/display.py | 6 +++--- awx/lib/tower_display_callback/events.py | 1 - awx/lib/tower_display_callback/module.py | 7 ++++--- awx/main/management/commands/run_callback_receiver.py | 1 - awx/main/models/ad_hoc_commands.py | 1 - awx/main/tasks.py | 4 ++++ awx/plugins/callback/minimal.py | 2 +- awx/plugins/callback/tower_display.py | 2 +- 12 files changed, 18 insertions(+), 17 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index c95cebcb9e..3fb9826519 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -10,9 +10,8 @@ import time import socket import sys import logging -from base64 import b64encode, b64decode +from base64 import b64encode from collections import OrderedDict -from HTMLParser import HTMLParser # Django from django.conf import settings diff --git a/awx/lib/sitecustomize.py b/awx/lib/sitecustomize.py index cf6cab211e..02ac2eba55 100644 --- a/awx/lib/sitecustomize.py +++ b/awx/lib/sitecustomize.py @@ -7,7 +7,7 @@ import sys def argv_ready(argv): if argv and os.path.basename(argv[0]) in {'ansible', 'ansible-playbook'}: - import tower_display_callback + import tower_display_callback # noqa class argv_placeholder(object): diff --git a/awx/lib/tower_display_callback/__init__.py b/awx/lib/tower_display_callback/__init__.py index 313a1d50c6..d984956c7f 100644 --- a/awx/lib/tower_display_callback/__init__.py +++ b/awx/lib/tower_display_callback/__init__.py @@ -18,8 +18,8 @@ from __future__ import (absolute_import, division, print_function) # Tower Display Callback -from . import cleanup # to register control persistent cleanup. -from . import display # to wrap ansible.display.Display methods. +from . import cleanup # noqa (registers control persistent cleanup) +from . import display # noqa (wraps ansible.display.Display methods) from .module import TowerDefaultCallbackModule, TowerMinimalCallbackModule __all__ = ['TowerDefaultCallbackModule', 'TowerMinimalCallbackModule'] diff --git a/awx/lib/tower_display_callback/cleanup.py b/awx/lib/tower_display_callback/cleanup.py index 1bc276f742..7a0387cddf 100644 --- a/awx/lib/tower_display_callback/cleanup.py +++ b/awx/lib/tower_display_callback/cleanup.py @@ -69,4 +69,4 @@ def terminate_ssh_control_masters(): proc.terminate() procs_gone, procs_alive = psutil.wait_procs(ssh_cm_procs, timeout=5) for proc in procs_alive: - proc.kill() \ No newline at end of file + proc.kill() diff --git a/awx/lib/tower_display_callback/display.py b/awx/lib/tower_display_callback/display.py index ec32fec334..5b1265201e 100644 --- a/awx/lib/tower_display_callback/display.py +++ b/awx/lib/tower_display_callback/display.py @@ -18,10 +18,7 @@ from __future__ import (absolute_import, division, print_function) # Python -import cgi -import contextlib import functools -import json import sys import uuid @@ -36,6 +33,7 @@ __all__ = [] def with_context(**context): global event_context + def wrap(f): @functools.wraps(f) def wrapper(*args, **kwargs): @@ -57,6 +55,7 @@ for attr in dir(Display): def with_verbosity(f): global event_context + @functools.wraps(f) def wrapper(*args, **kwargs): host = args[2] if len(args) >= 3 else kwargs.get('host', None) @@ -72,6 +71,7 @@ Display.verbose = with_verbosity(Display.verbose) def display_with_context(f): + @functools.wraps(f) def wrapper(*args, **kwargs): log_only = args[5] if len(args) >= 6 else kwargs.get('log_only', False) diff --git a/awx/lib/tower_display_callback/events.py b/awx/lib/tower_display_callback/events.py index fa664e5856..ad7eb6418e 100644 --- a/awx/lib/tower_display_callback/events.py +++ b/awx/lib/tower_display_callback/events.py @@ -19,7 +19,6 @@ from __future__ import (absolute_import, division, print_function) # Python import base64 -import cgi import contextlib import datetime import json diff --git a/awx/lib/tower_display_callback/module.py b/awx/lib/tower_display_callback/module.py index 7be4835aa7..e5b4e21713 100644 --- a/awx/lib/tower_display_callback/module.py +++ b/awx/lib/tower_display_callback/module.py @@ -20,7 +20,6 @@ from __future__ import (absolute_import, division, print_function) # Python import contextlib import copy -import os import re import sys import uuid @@ -209,8 +208,10 @@ class BaseCallbackModule(CallbackBase): default=default, ) with self.capture_event_data('playbook_on_vars_prompt', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_vars_prompt(varname, - private, prompt, encrypt, confirm, salt_size, salt, default) + super(BaseCallbackModule, self).v2_playbook_on_vars_prompt( + varname, private, prompt, encrypt, confirm, salt_size, salt, + default, + ) def v2_playbook_on_include(self, included_file): event_data = dict( diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index e99a34aa4f..6381660948 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -3,7 +3,6 @@ # Python import logging -import json from kombu import Connection, Exchange, Queue from kombu.mixins import ConsumerMixin diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index fc56802160..c81531d22c 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -10,7 +10,6 @@ from urlparse import urljoin # Django from django.conf import settings -from django.core.cache import cache from django.db import models from django.utils.dateparse import parse_datetime from django.utils.text import Truncator diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 503509890b..cd785ef96a 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -991,9 +991,11 @@ class RunJob(BaseTask): Wrap stdout file object to capture events. ''' stdout_handle = super(RunJob, self).get_stdout_handle(instance) + def job_event_callback(event_data): event_data.setdefault('job_id', instance.id) JobEvent.create_from_data(**event_data) + return OutputEventFilter(stdout_handle, job_event_callback) def get_ssh_key_path(self, instance, **kwargs): @@ -1716,9 +1718,11 @@ class RunAdHocCommand(BaseTask): Wrap stdout file object to capture events. ''' stdout_handle = super(RunAdHocCommand, self).get_stdout_handle(instance) + def ad_hoc_command_event_callback(event_data): event_data.setdefault('ad_hoc_command_id', instance.id) AdHocCommandEvent.create_from_data(**event_data) + return OutputEventFilter(stdout_handle, ad_hoc_command_event_callback) def get_ssh_key_path(self, instance, **kwargs): diff --git a/awx/plugins/callback/minimal.py b/awx/plugins/callback/minimal.py index e34ebdd78a..fcbaa76d55 100644 --- a/awx/plugins/callback/minimal.py +++ b/awx/plugins/callback/minimal.py @@ -27,4 +27,4 @@ if awx_lib_path not in sys.path: sys.path.insert(0, awx_lib_path) # Tower Display Callback -from tower_display_callback import TowerMinimalCallbackModule as CallbackModule +from tower_display_callback import TowerMinimalCallbackModule as CallbackModule # noqa diff --git a/awx/plugins/callback/tower_display.py b/awx/plugins/callback/tower_display.py index 36f6a29537..725232dfe4 100644 --- a/awx/plugins/callback/tower_display.py +++ b/awx/plugins/callback/tower_display.py @@ -27,4 +27,4 @@ if awx_lib_path not in sys.path: sys.path.insert(0, awx_lib_path) # Tower Display Callback -from tower_display_callback import TowerDefaultCallbackModule as CallbackModule +from tower_display_callback import TowerDefaultCallbackModule as CallbackModule # noqa From 908eef13f65008dd7081b26375f741fd79011141 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Fri, 28 Oct 2016 11:37:30 -0400 Subject: [PATCH 11/14] Renamed job event migration. --- ...4_v310_job_event_stdout.py => 0045_v310_job_event_stdout.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0044_v310_job_event_stdout.py => 0045_v310_job_event_stdout.py} (98%) diff --git a/awx/main/migrations/0044_v310_job_event_stdout.py b/awx/main/migrations/0045_v310_job_event_stdout.py similarity index 98% rename from awx/main/migrations/0044_v310_job_event_stdout.py rename to awx/main/migrations/0045_v310_job_event_stdout.py index 8a66aa9f94..27bce05632 100644 --- a/awx/main/migrations/0044_v310_job_event_stdout.py +++ b/awx/main/migrations/0045_v310_job_event_stdout.py @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0043_v310_scm_revision'), + ('main', '0044_v310_project_playbook_files'), ] operations = [ From c43334f8f44549eabdb86a4e1afd7566bba9ead0 Mon Sep 17 00:00:00 2001 From: Chris Church Date: Fri, 28 Oct 2016 21:58:03 -0400 Subject: [PATCH 12/14] Update job events based on how they are used in Ansible 2.x. --- awx/lib/tower_display_callback/module.py | 114 ++++++++++++------ .../migrations/0045_v310_job_event_stdout.py | 2 +- awx/main/models/ad_hoc_commands.py | 14 +-- awx/main/models/jobs.py | 32 +++-- 4 files changed, 107 insertions(+), 55 deletions(-) diff --git a/awx/lib/tower_display_callback/module.py b/awx/lib/tower_display_callback/module.py index e5b4e21713..02b30ef2bc 100644 --- a/awx/lib/tower_display_callback/module.py +++ b/awx/lib/tower_display_callback/module.py @@ -196,7 +196,6 @@ class BaseCallbackModule(CallbackBase): def v2_playbook_on_vars_prompt(self, varname, private=True, prompt=None, encrypt=None, confirm=False, salt_size=None, salt=None, default=None): - return # not currently used in v2 (yet) - FIXME: Confirm this is still the case? event_data = dict( varname=varname, private=private, @@ -239,17 +238,17 @@ class BaseCallbackModule(CallbackBase): super(BaseCallbackModule, self).v2_playbook_on_play_start(play) def v2_playbook_on_import_for_host(self, result, imported_file): - return # not currently used in v2 (yet) / don't care about recording this one + # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_import_for_host'): super(BaseCallbackModule, self).v2_playbook_on_import_for_host(result, imported_file) def v2_playbook_on_not_import_for_host(self, result, missing_file): - return # not currently used in v2 (yet) / don't care about recording this one + # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_not_import_for_host'): super(BaseCallbackModule, self).v2_playbook_on_not_import_for_host(result, missing_file) def v2_playbook_on_setup(self): - return # not currently used in v2 (yet) + # NOTE: Not used by Ansible 2.x. with self.capture_event_data('playbook_on_setup'): super(BaseCallbackModule, self).v2_playbook_on_setup() @@ -257,6 +256,9 @@ class BaseCallbackModule(CallbackBase): # FIXME: Flag task path output as vv. task_uuid = str(task._uuid) if task_uuid in self.task_uuids: + # FIXME: When this task UUID repeats, it means the play is using the + # free strategy, so different hosts may be running different tasks + # within a play. return self.task_uuids.add(task_uuid) self.set_task(task) @@ -270,27 +272,27 @@ class BaseCallbackModule(CallbackBase): super(BaseCallbackModule, self).v2_playbook_on_task_start(task, is_conditional) def v2_playbook_on_cleanup_task_start(self, task): - # re-using playbook_on_task_start event here for this v2-specific - # event, though we may consider any changes necessary to distinguish - # this from a normal task FIXME! + # NOTE: Not used by Ansible 2.x. self.set_task(task) event_data = dict( task=task, name=task.get_name(), uuid=str(task._uuid), + is_conditional=True, ) with self.capture_event_data('playbook_on_task_start', **event_data): super(BaseCallbackModule, self).v2_playbook_on_cleanup_task_start(task) def v2_playbook_on_handler_task_start(self, task): - # re-using playbook_on_task_start event here for this v2-specific - # event, though we may consider any changes necessary to distinguish - # this from a normal task FIXME! + # NOTE: Re-using playbook_on_task_start event for this v2-specific + # event, but setting is_conditional=True, which is how v1 identified a + # task run as a handler. self.set_task(task) event_data = dict( task=task, name=task.get_name(), uuid=str(task._uuid), + is_conditional=True, ) with self.capture_event_data('playbook_on_task_start', **event_data): super(BaseCallbackModule, self).v2_playbook_on_handler_task_start(task) @@ -304,8 +306,9 @@ class BaseCallbackModule(CallbackBase): super(BaseCallbackModule, self).v2_playbook_on_no_hosts_remaining() def v2_playbook_on_notify(self, result, handler): + # NOTE: Not used by Ansible 2.x. event_data = dict( - host=result._host.name, + host=result._host.get_name(), task=result._task, handler=handler, ) @@ -329,7 +332,7 @@ class BaseCallbackModule(CallbackBase): def v2_runner_on_ok(self, result): # FIXME: Display detailed results or not based on verbosity. event_data = dict( - host=result._host.name, + host=result._host.get_name(), remote_addr=result._host.address, task=result._task, res=result._result, @@ -341,7 +344,8 @@ class BaseCallbackModule(CallbackBase): def v2_runner_on_failed(self, result, ignore_errors=False): # FIXME: Add verbosity for exception/results output. event_data = dict( - host=result._host.name, + host=result._host.get_name(), + remote_addr=result._host.address, res=result._result, task=result._task, ignore_errors=ignore_errors, @@ -350,12 +354,10 @@ class BaseCallbackModule(CallbackBase): with self.capture_event_data('runner_on_failed', **event_data): super(BaseCallbackModule, self).v2_runner_on_failed(result, ignore_errors) - def v2_runner_on_error(self, result): - pass # Not implemented in v2. - def v2_runner_on_skipped(self, result): event_data = dict( - host=result._host.name, + host=result._host.get_name(), + remote_addr=result._host.address, task=result._task, event_loop=result._task.loop if hasattr(result._task, 'loop') else None, ) @@ -364,7 +366,8 @@ class BaseCallbackModule(CallbackBase): def v2_runner_on_unreachable(self, result): event_data = dict( - host=result._host.name, + host=result._host.get_name(), + remote_addr=result._host.address, task=result._task, res=result._result, ) @@ -372,25 +375,69 @@ class BaseCallbackModule(CallbackBase): super(BaseCallbackModule, self).v2_runner_on_unreachable(result) def v2_runner_on_no_hosts(self, task): + # NOTE: Not used by Ansible 2.x. event_data = dict( task=task, ) with self.capture_event_data('runner_on_no_hosts', **event_data): super(BaseCallbackModule, self).v2_runner_on_no_hosts(task) - def v2_runner_on_file_diff(self, result, diff): - # FIXME: Ignore file diff for ad hoc commands? + def v2_runner_on_async_poll(self, result): + # NOTE: Not used by Ansible 2.x. event_data = dict( - host=result._host.name, + host=result._host.get_name(), + task=result._task, + res=result._result, + jid=result._result.get('ansible_job_id'), + ) + with self.capture_event_data('runner_on_async_poll', **event_data): + super(BaseCallbackModule, self).v2_runner_on_async_poll(result) + + def v2_runner_on_async_ok(self, result): + # NOTE: Not used by Ansible 2.x. + event_data = dict( + host=result._host.get_name(), + task=result._task, + res=result._result, + jid=result._result.get('ansible_job_id'), + ) + with self.capture_event_data('runner_on_async_ok', **event_data): + super(BaseCallbackModule, self).v2_runner_on_async_ok(result) + + def v2_runner_on_async_failed(self, result): + # NOTE: Not used by Ansible 2.x. + event_data = dict( + host=result._host.get_name(), + task=result._task, + res=result._result, + jid=result._result.get('ansible_job_id'), + ) + with self.capture_event_data('runner_on_async_failed', **event_data): + super(BaseCallbackModule, self).v2_runner_on_async_failed(result) + + def v2_runner_on_file_diff(self, result, diff): + # NOTE: Not used by Ansible 2.x. + event_data = dict( + host=result._host.get_name(), task=result._task, diff=diff, ) with self.capture_event_data('runner_on_file_diff', **event_data): super(BaseCallbackModule, self).v2_runner_on_file_diff(result, diff) + def v2_on_file_diff(self, result): + # NOTE: Logged as runner_on_file_diff. + event_data = dict( + host=result._host.get_name(), + task=result._task, + diff=result._result.get('diff'), + ) + with self.capture_event_data('runner_on_file_diff', **event_data): + super(BaseCallbackModule, self).v2_on_file_diff(result) + def v2_runner_item_on_ok(self, result): event_data = dict( - host=result._host.name, + host=result._host.get_name(), task=result._task, res=result._result, ) @@ -399,7 +446,7 @@ class BaseCallbackModule(CallbackBase): def v2_runner_item_on_failed(self, result): event_data = dict( - host=result._host.name, + host=result._host.get_name(), task=result._task, res=result._result, ) @@ -408,24 +455,21 @@ class BaseCallbackModule(CallbackBase): def v2_runner_item_on_skipped(self, result): event_data = dict( - host=result._host.name, + host=result._host.get_name(), task=result._task, res=result._result, ) with self.capture_event_data('runner_item_on_skipped', **event_data): super(BaseCallbackModule, self).v2_runner_item_on_skipped(result) - # V2 does not use the _on_async callbacks (yet). - - def runner_on_async_poll(self, host, res, jid, clock): - self._log_event('runner_on_async_poll', host=host, res=res, jid=jid, - clock=clock) - - def runner_on_async_ok(self, host, res, jid): - self._log_event('runner_on_async_ok', host=host, res=res, jid=jid) - - def runner_on_async_failed(self, host, res, jid): - self._log_event('runner_on_async_failed', host=host, res=res, jid=jid) + def v2_runner_retry(self, result): + event_data = dict( + host=result._host.get_name(), + task=result._task, + res=result._result, + ) + with self.capture_event_data('runner_retry', **event_data): + super(BaseCallbackModule, self).v2_runner_retry(result) class TowerDefaultCallbackModule(BaseCallbackModule, DefaultCallbackModule): diff --git a/awx/main/migrations/0045_v310_job_event_stdout.py b/awx/main/migrations/0045_v310_job_event_stdout.py index 27bce05632..e3325ddb6b 100644 --- a/awx/main/migrations/0045_v310_job_event_stdout.py +++ b/awx/main/migrations/0045_v310_job_event_stdout.py @@ -79,7 +79,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='jobevent', name='event', - field=models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_error', 'Host Failure'), (b'runner_on_skipped', 'Host Skipped'), (b'runner_on_unreachable', 'Host Unreachable'), (b'runner_on_no_hosts', 'No Hosts Remaining'), (b'runner_on_async_poll', 'Host Polling'), (b'runner_on_async_ok', 'Host Async OK'), (b'runner_on_async_failed', 'Host Async Failure'), (b'runner_on_file_diff', 'File Difference'), (b'playbook_on_start', 'Playbook Started'), (b'playbook_on_notify', 'Running Handlers'), (b'playbook_on_no_hosts_matched', 'No Hosts Matched'), (b'playbook_on_no_hosts_remaining', 'No Hosts Remaining'), (b'playbook_on_task_start', 'Task Started'), (b'playbook_on_vars_prompt', 'Variables Prompted'), (b'playbook_on_setup', 'Gathering Facts'), (b'playbook_on_import_for_host', 'internal: on Import for Host'), (b'playbook_on_not_import_for_host', 'internal: on Not Import for Host'), (b'playbook_on_play_start', 'Play Started'), (b'playbook_on_stats', 'Playbook Complete'), (b'debug', 'Debug'), (b'verbose', 'Verbose'), (b'deprecated', 'Deprecated'), (b'warning', 'Warning'), (b'system_warning', 'System Warning'), (b'error', 'Error')]), + field=models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_error', 'Host Failure'), (b'runner_on_skipped', 'Host Skipped'), (b'runner_on_unreachable', 'Host Unreachable'), (b'runner_on_no_hosts', 'No Hosts Remaining'), (b'runner_on_async_poll', 'Host Polling'), (b'runner_on_async_ok', 'Host Async OK'), (b'runner_on_async_failed', 'Host Async Failure'), (b'runner_item_on_ok', 'Item OK'), (b'runner_item_on_failed', 'Item Failed'), (b'runner_item_on_skipped', 'Item Skipped'), (b'runner_retry', 'Host Retry'), (b'runner_on_file_diff', 'File Difference'), (b'playbook_on_start', 'Playbook Started'), (b'playbook_on_notify', 'Running Handlers'), (b'playbook_on_include', 'Including File'), (b'playbook_on_no_hosts_matched', 'No Hosts Matched'), (b'playbook_on_no_hosts_remaining', 'No Hosts Remaining'), (b'playbook_on_task_start', 'Task Started'), (b'playbook_on_vars_prompt', 'Variables Prompted'), (b'playbook_on_setup', 'Gathering Facts'), (b'playbook_on_import_for_host', 'internal: on Import for Host'), (b'playbook_on_not_import_for_host', 'internal: on Not Import for Host'), (b'playbook_on_play_start', 'Play Started'), (b'playbook_on_stats', 'Playbook Complete'), (b'debug', 'Debug'), (b'verbose', 'Verbose'), (b'deprecated', 'Deprecated'), (b'warning', 'Warning'), (b'system_warning', 'System Warning'), (b'error', 'Error')]), ), migrations.AlterUniqueTogether( name='adhoccommandevent', diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index c81531d22c..65f40427b0 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -260,16 +260,16 @@ class AdHocCommandEvent(CreatedModifiedModel): ('runner_on_ok', _('Host OK'), False), ('runner_on_unreachable', _('Host Unreachable'), True), # Tower won't see no_hosts (check is done earlier without callback). - #('runner_on_no_hosts', _('No Hosts Matched'), False), + # ('runner_on_no_hosts', _('No Hosts Matched'), False), # Tower will see skipped (when running in check mode for a module that # does not support check mode). ('runner_on_skipped', _('Host Skipped'), False), - # Tower does not support async for ad hoc commands. - #('runner_on_async_poll', _('Host Polling'), False), - #('runner_on_async_ok', _('Host Async OK'), False), - #('runner_on_async_failed', _('Host Async Failure'), True), - # Tower does not yet support --diff mode - #('runner_on_file_diff', _('File Difference'), False), + # Tower does not support async for ad hoc commands (not used in v2). + # ('runner_on_async_poll', _('Host Polling'), False), + # ('runner_on_async_ok', _('Host Async OK'), False), + # ('runner_on_async_failed', _('Host Async Failure'), True), + # Tower does not yet support --diff mode. + # ('runner_on_file_diff', _('File Difference'), False), # Additional event types for captured stdout not directly related to # runner events. diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 74a8395a2a..a15e291d78 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -936,11 +936,12 @@ class JobEvent(CreatedModifiedModel): # - playbook_on_vars_prompt (for each play, but before play starts, we # currently don't handle responding to these prompts) # - playbook_on_play_start (once for each play) - # - playbook_on_import_for_host - # - playbook_on_not_import_for_host + # - playbook_on_import_for_host (not logged, not used for v2) + # - playbook_on_not_import_for_host (not logged, not used for v2) # - playbook_on_no_hosts_matched # - playbook_on_no_hosts_remaining - # - playbook_on_setup + # - playbook_on_include (only v2 - only used for handlers?) + # - playbook_on_setup (not used for v2) # - runner_on* # - playbook_on_task_start (once for each task within a play) # - runner_on_failed @@ -948,12 +949,16 @@ class JobEvent(CreatedModifiedModel): # - runner_on_error (not used for v2) # - runner_on_skipped # - runner_on_unreachable - # - runner_on_no_hosts - # - runner_on_async_poll - # - runner_on_async_ok - # - runner_on_async_failed - # - runner_on_file_diff - # - playbook_on_notify (once for each notification from the play) + # - runner_on_no_hosts (not used for v2) + # - runner_on_async_poll (not used for v2) + # - runner_on_async_ok (not used for v2) + # - runner_on_async_failed (not used for v2) + # - runner_on_file_diff (v2 event is v2_on_file_diff) + # - runner_item_on_ok (v2 only) + # - runner_item_on_failed (v2 only) + # - runner_item_on_skipped (v2 only) + # - runner_retry (v2 only) + # - playbook_on_notify (once for each notification from the play, not used for v2) # - playbook_on_stats EVENT_TYPES = [ @@ -967,19 +972,22 @@ class JobEvent(CreatedModifiedModel): (3, 'runner_on_async_poll', _('Host Polling'), False), (3, 'runner_on_async_ok', _('Host Async OK'), False), (3, 'runner_on_async_failed', _('Host Async Failure'), True), - # Tower does not yet support --diff mode + (3, 'runner_item_on_ok', _('Item OK'), False), + (3, 'runner_item_on_failed', _('Item Failed'), True), + (3, 'runner_item_on_skipped', _('Item Skipped'), False), + (3, 'runner_retry', _('Host Retry'), False), + # Tower does not yet support --diff mode. (3, 'runner_on_file_diff', _('File Difference'), False), (0, 'playbook_on_start', _('Playbook Started'), False), (2, 'playbook_on_notify', _('Running Handlers'), False), + (2, 'playbook_on_include', _('Including File'), False), (2, 'playbook_on_no_hosts_matched', _('No Hosts Matched'), False), (2, 'playbook_on_no_hosts_remaining', _('No Hosts Remaining'), False), (2, 'playbook_on_task_start', _('Task Started'), False), # Tower does not yet support vars_prompt (and will probably hang :) (1, 'playbook_on_vars_prompt', _('Variables Prompted'), False), (2, 'playbook_on_setup', _('Gathering Facts'), False), - # callback will not record this (2, 'playbook_on_import_for_host', _('internal: on Import for Host'), False), - # callback will not record this (2, 'playbook_on_not_import_for_host', _('internal: on Not Import for Host'), False), (1, 'playbook_on_play_start', _('Play Started'), False), (1, 'playbook_on_stats', _('Playbook Complete'), False), From 31faca2b4f3de37502c1025200cb2b9e6404e61b Mon Sep 17 00:00:00 2001 From: Chris Church Date: Fri, 28 Oct 2016 22:32:49 -0400 Subject: [PATCH 13/14] Add option to use callback queue for job events. --- awx/main/queue.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++- awx/main/tasks.py | 27 ++++++++++++++++++++------ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/awx/main/queue.py b/awx/main/queue.py index b0b8d0374e..bfb487441f 100644 --- a/awx/main/queue.py +++ b/awx/main/queue.py @@ -1,9 +1,19 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +# Python import json +import logging +import os + +# Django +from django.conf import settings + +# Kombu +from kombu import Connection, Exchange, Producer + +__all__ = ['FifoQueue', 'CallbackQueueDispatcher'] -__all__ = ['FifoQueue'] # TODO: Figure out wtf to do with this class class FifoQueue(object): @@ -33,3 +43,39 @@ class FifoQueue(object): answer = None if answer: return json.loads(answer) + + +class CallbackQueueDispatcher(object): + + def __init__(self): + self.callback_connection = getattr(settings, 'CALLBACK_CONNECTION', None) + self.connection_queue = getattr(settings, 'CALLBACK_QUEUE', '') + self.connection = None + self.exchange = None + self.logger = logging.getLogger('awx.main.queue.CallbackQueueDispatcher') + + def dispatch(self, obj): + if not self.callback_connection or not self.connection_queue: + return + active_pid = os.getpid() + for retry_count in xrange(4): + try: + if not hasattr(self, 'connection_pid'): + self.connection_pid = active_pid + if self.connection_pid != active_pid: + self.connection = None + if self.connection is None: + self.connection = Connection(self.callback_connection) + self.exchange = Exchange(self.connection_queue, type='direct') + + producer = Producer(self.connection) + producer.publish(obj, + serializer='json', + compression='bzip2', + exchange=self.exchange, + declare=[self.exchange], + routing_key=self.connection_queue) + return + except Exception, e: + self.logger.info('Publish Job Event Exception: %r, retry=%d', e, + retry_count, exc_info=True) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index cd785ef96a..35858577a7 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -47,6 +47,7 @@ from django.contrib.auth.models import User from awx.main.constants import CLOUD_PROVIDERS from awx.main.models import * # noqa from awx.main.models import UnifiedJob +from awx.main.queue import CallbackQueueDispatcher from awx.main.task_engine import TaskEnhancer from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, check_proot_installed, build_proot_temp_dir, wrap_args_with_proot, @@ -991,10 +992,17 @@ class RunJob(BaseTask): Wrap stdout file object to capture events. ''' stdout_handle = super(RunJob, self).get_stdout_handle(instance) + + if getattr(settings, 'USE_CALLBACK_QUEUE', False): + dispatcher = CallbackQueueDispatcher() - def job_event_callback(event_data): - event_data.setdefault('job_id', instance.id) - JobEvent.create_from_data(**event_data) + def job_event_callback(event_data): + event_data.setdefault('job_id', instance.id) + dispatcher.dispatch(event_data) + else: + def job_event_callback(event_data): + event_data.setdefault('job_id', instance.id) + JobEvent.create_from_data(**event_data) return OutputEventFilter(stdout_handle, job_event_callback) @@ -1719,9 +1727,16 @@ class RunAdHocCommand(BaseTask): ''' stdout_handle = super(RunAdHocCommand, self).get_stdout_handle(instance) - def ad_hoc_command_event_callback(event_data): - event_data.setdefault('ad_hoc_command_id', instance.id) - AdHocCommandEvent.create_from_data(**event_data) + if getattr(settings, 'USE_CALLBACK_QUEUE', False): + dispatcher = CallbackQueueDispatcher() + + def ad_hoc_command_event_callback(event_data): + event_data.setdefault('ad_hoc_command_id', instance.id) + dispatcher.dispatch(event_data) + else: + def ad_hoc_command_event_callback(event_data): + event_data.setdefault('ad_hoc_command_id', instance.id) + AdHocCommandEvent.create_from_data(**event_data) return OutputEventFilter(stdout_handle, ad_hoc_command_event_callback) From 4df56edb9fd2db9370d23f5ae8fe48f168bbbdbe Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Mon, 31 Oct 2016 13:01:14 -0400 Subject: [PATCH 14/14] Move browser-sync & file-polling deps to optionalDependencies field, update npm engine field, shrinkwrap new dependency tree. --- awx/ui/npm-shrinkwrap.json | 608 ++++++++++++++++++++----------------- awx/ui/package.json | 17 +- 2 files changed, 335 insertions(+), 290 deletions(-) diff --git a/awx/ui/npm-shrinkwrap.json b/awx/ui/npm-shrinkwrap.json index 656c931f8b..f4cce2939f 100644 --- a/awx/ui/npm-shrinkwrap.json +++ b/awx/ui/npm-shrinkwrap.json @@ -4,7 +4,7 @@ "dependencies": { "abbrev": { "version": "1.0.9", - "from": "abbrev@>=1.0.0 <1.1.0", + "from": "abbrev@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz" }, "accepts": { @@ -102,9 +102,9 @@ "resolved": "https://registry.npmjs.org/angular-gettext-tools/-/angular-gettext-tools-2.3.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -143,7 +143,7 @@ "angular-tz-extensions": { "version": "0.3.11", "from": "chouseknecht/angular-tz-extensions", - "resolved": "git://github.com/chouseknecht/angular-tz-extensions.git#a9b70c69ba27a19e1b1f9facbd85e870060aace9", + "resolved": "git://github.com/chouseknecht/angular-tz-extensions.git#938577310ff9a343eae1348aa04a3ed1a96d097f", "dependencies": { "angular": { "version": "1.4.7", @@ -166,8 +166,8 @@ }, "angular-tz-extensions": { "version": "0.3.11", - "from": "chouseknecht/angular-tz-extensions#0.3.11", - "resolved": "git://github.com/chouseknecht/angular-tz-extensions.git#a9b70c69ba27a19e1b1f9facbd85e870060aace9", + "from": "chouseknecht/angular-tz-extensions#0.3.12", + "resolved": "git://github.com/chouseknecht/angular-tz-extensions.git#938577310ff9a343eae1348aa04a3ed1a96d097f", "dependencies": { "angular": { "version": "1.4.7", @@ -206,20 +206,10 @@ "from": "archiver@1.1.0", "resolved": "https://registry.npmjs.org/archiver/-/archiver-1.1.0.tgz", "dependencies": { - "async": { - "version": "2.1.2", - "from": "async@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.1.2.tgz" - }, - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -228,15 +218,10 @@ "from": "archiver-utils@>=1.3.0 <2.0.0", "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -297,7 +282,7 @@ }, "arrify": { "version": "1.0.1", - "from": "arrify@>=1.0.1 <2.0.0", + "from": "arrify@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz" }, "asap": { @@ -306,9 +291,9 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.5.tgz" }, "asn1": { - "version": "0.2.3", - "from": "asn1@>=0.2.3 <0.3.0", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz" + "version": "0.1.11", + "from": "asn1@0.1.11", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.1.11.tgz" }, "assert": { "version": "1.4.1", @@ -316,14 +301,21 @@ "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz" }, "assert-plus": { - "version": "0.2.0", - "from": "assert-plus@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" + "version": "0.1.5", + "from": "assert-plus@>=0.1.5 <0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" }, "async": { - "version": "1.5.2", - "from": "async@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + "version": "2.1.2", + "from": "async@>=2.0.1 <3.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-2.1.2.tgz", + "dependencies": { + "lodash": { + "version": "4.16.5", + "from": "lodash@>=4.14.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" + } + } }, "async-each": { "version": "1.0.1", @@ -366,9 +358,9 @@ "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" }, "source-map": { "version": "0.5.6", @@ -383,9 +375,9 @@ "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" }, "source-map": { "version": "0.5.6", @@ -405,9 +397,9 @@ "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -437,9 +429,9 @@ "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -479,9 +471,9 @@ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -596,9 +588,19 @@ "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.1 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" } } }, @@ -613,9 +615,9 @@ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.16.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -625,9 +627,9 @@ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -637,9 +639,9 @@ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -795,29 +797,7 @@ "browser-sync": { "version": "2.17.5", "from": "browser-sync@>=2.14.0 <3.0.0", - "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.17.5.tgz", - "dependencies": { - "cliui": { - "version": "3.2.0", - "from": "cliui@>=3.2.0 <4.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz" - }, - "qs": { - "version": "6.2.1", - "from": "qs@6.2.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.1.tgz" - }, - "window-size": { - "version": "0.2.0", - "from": "window-size@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz" - }, - "yargs": { - "version": "6.0.0", - "from": "yargs@6.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-6.0.0.tgz" - } - } + "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.17.5.tgz" }, "browser-sync-client": { "version": "2.4.3", @@ -876,7 +856,7 @@ }, "camelcase": { "version": "1.2.1", - "from": "camelcase@>=1.0.2 <2.0.0", + "from": "camelcase@>=1.2.1 <2.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz" }, "camelcase-keys": { @@ -892,9 +872,9 @@ } }, "caniuse-db": { - "version": "1.0.30000569", + "version": "1.0.30000572", "from": "caniuse-db@>=1.0.30000554 <2.0.0", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000569.tgz" + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000572.tgz" }, "caseless": { "version": "0.11.0", @@ -924,14 +904,7 @@ "cli": { "version": "1.0.1", "from": "cli@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.1.1 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - } - } + "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz" }, "cli-width": { "version": "1.1.1", @@ -939,16 +912,9 @@ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-1.1.1.tgz" }, "cliui": { - "version": "2.1.0", - "from": "cliui@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "dependencies": { - "wordwrap": { - "version": "0.0.2", - "from": "wordwrap@0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" - } - } + "version": "3.2.0", + "from": "cliui@>=3.0.3 <4.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz" }, "clone": { "version": "1.0.2", @@ -981,9 +947,9 @@ "resolved": "https://registry.npmjs.org/combine-lists/-/combine-lists-1.0.1.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.5.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -994,7 +960,7 @@ }, "commander": { "version": "2.9.0", - "from": "commander@>=2.9.0 <3.0.0", + "from": "commander@>=2.2.0 <3.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" }, "commondir": { @@ -1217,7 +1183,7 @@ }, "debug": { "version": "2.2.0", - "from": "debug@>=2.1.1 <3.0.0", + "from": "debug@>=2.2.0 <2.3.0", "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" }, "decamelize": { @@ -1400,7 +1366,7 @@ }, "errno": { "version": "0.1.4", - "from": "errno@>=0.1.1 <0.2.0", + "from": "errno@>=0.1.3 <0.2.0", "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz" }, "error-ex": { @@ -1527,11 +1493,6 @@ "from": "connect@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/connect/-/connect-1.9.2.tgz" }, - "mkdirp": { - "version": "0.3.0", - "from": "mkdirp@0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" - }, "qs": { "version": "0.4.2", "from": "qs@>=0.4.0 <0.5.0", @@ -1559,6 +1520,11 @@ "from": "debug@0.7.4", "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, "mkdirp": { "version": "0.5.0", "from": "mkdirp@0.5.0", @@ -1606,6 +1572,11 @@ "from": "fileset@>=0.2.0 <0.3.0", "resolved": "https://registry.npmjs.org/fileset/-/fileset-0.2.1.tgz", "dependencies": { + "glob": { + "version": "5.0.15", + "from": "glob@>=5.0.0 <6.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + }, "minimatch": { "version": "2.0.10", "from": "minimatch@>=2.0.0 <3.0.0", @@ -1626,7 +1597,19 @@ "find-cache-dir": { "version": "0.1.1", "from": "find-cache-dir@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz" + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz", + "dependencies": { + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.1 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + } + } }, "find-up": { "version": "1.1.2", @@ -1636,7 +1619,14 @@ "findup-sync": { "version": "0.3.0", "from": "findup-sync@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz" + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz", + "dependencies": { + "glob": { + "version": "5.0.15", + "from": "glob@>=5.0.0 <5.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + } + } }, "for-in": { "version": "0.1.6", @@ -1654,9 +1644,9 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" }, "form-data": { - "version": "2.1.1", - "from": "form-data@>=2.1.1 <2.2.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.1.tgz" + "version": "1.0.1", + "from": "form-data@>=1.0.0-rc3 <1.1.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.1.tgz" }, "formidable": { "version": "1.0.17", @@ -1673,11 +1663,6 @@ "from": "fresh@>=0.3.0 <0.4.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.3.0.tgz" }, - "fs": { - "version": "0.0.2", - "from": "fs@0.0.2", - "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.2.tgz" - }, "fs-access": { "version": "1.0.1", "from": "fs-access@>=1.0.0 <2.0.0", @@ -2343,9 +2328,9 @@ } }, "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.0 <6.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + "version": "7.1.1", + "from": "glob@>=7.0.5 <8.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" }, "glob-base": { "version": "0.3.0", @@ -2367,15 +2352,10 @@ "from": "globule@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/globule/-/globule-1.1.0.tgz", "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.1.1 <7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.16.4 <4.17.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, @@ -2389,11 +2369,28 @@ "from": "graceful-readlink@>=1.0.0", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" }, + "grunt-browser-sync": { + "version": "2.2.0", + "from": "grunt-browser-sync@>=2.2.0 <3.0.0", + "resolved": "https://registry.npmjs.org/grunt-browser-sync/-/grunt-browser-sync-2.2.0.tgz" + }, "grunt-cli": { "version": "1.2.0", "from": "grunt-cli@>=1.2.0 <2.0.0", "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz" }, + "grunt-contrib-watch": { + "version": "1.0.0", + "from": "grunt-contrib-watch@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-watch/-/grunt-contrib-watch-1.0.0.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.5.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + } + } + }, "grunt-known-options": { "version": "1.1.0", "from": "grunt-known-options@>=1.1.0 <1.2.0", @@ -2421,6 +2418,11 @@ "from": "grunt-legacy-util@>=1.0.0 <1.1.0", "resolved": "https://registry.npmjs.org/grunt-legacy-util/-/grunt-legacy-util-1.0.0.tgz", "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.5.2 <1.6.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, "lodash": { "version": "4.3.0", "from": "lodash@>=4.3.0 <4.4.0", @@ -2433,6 +2435,11 @@ "from": "handlebars@>=4.0.0 <4.1.0", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.5.tgz", "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, "source-map": { "version": "0.4.4", "from": "source-map@>=0.4.4 <0.5.0", @@ -2442,7 +2449,7 @@ }, "har-validator": { "version": "2.0.6", - "from": "har-validator@>=2.0.6 <2.1.0", + "from": "har-validator@>=2.0.2 <2.1.0", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-2.0.6.tgz" }, "has-ansi": { @@ -2479,7 +2486,7 @@ }, "hawk": { "version": "3.1.3", - "from": "hawk@>=3.1.3 <3.2.0", + "from": "hawk@>=3.1.0 <3.2.0", "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz" }, "hoek": { @@ -2572,16 +2579,16 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz" }, "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.16.2 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } }, "http-signature": { - "version": "1.1.1", - "from": "http-signature@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz" + "version": "0.11.0", + "from": "http-signature@>=0.11.0 <0.12.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.11.0.tgz" }, "https-browserify": { "version": "0.0.0", @@ -2595,7 +2602,7 @@ }, "iconv-lite": { "version": "0.4.13", - "from": "iconv-lite@>=0.4.13 <0.5.0", + "from": "iconv-lite@0.4.13", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" }, "ieee754": { @@ -2630,7 +2637,7 @@ }, "inherits": { "version": "2.0.3", - "from": "inherits@>=2.0.0 <3.0.0", + "from": "inherits@>=2.0.1 <3.0.0", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" }, "inquirer": { @@ -2727,7 +2734,7 @@ }, "is-glob": { "version": "2.0.1", - "from": "is-glob@>=2.0.1 <3.0.0", + "from": "is-glob@>=2.0.0 <3.0.0", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" }, "is-my-json-valid": { @@ -2800,10 +2807,35 @@ "from": "istanbul@>=0.4.0 <0.5.0", "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, + "glob": { + "version": "5.0.15", + "from": "glob@>=5.0.15 <6.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + }, "supports-color": { "version": "3.1.2", "from": "supports-color@>=3.1.0 <4.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" + }, + "wordwrap": { + "version": "1.0.0", + "from": "wordwrap@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" } } }, @@ -2962,6 +2994,16 @@ "from": "mime@>=1.2.11 <2.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + }, "source-map": { "version": "0.5.6", "from": "source-map@>=0.5.3 <0.6.0", @@ -2999,61 +3041,6 @@ "from": "localtunnel@1.8.1", "resolved": "https://registry.npmjs.org/localtunnel/-/localtunnel-1.8.1.tgz", "dependencies": { - "asn1": { - "version": "0.1.11", - "from": "asn1@0.1.11", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.1.11.tgz" - }, - "assert-plus": { - "version": "0.1.5", - "from": "assert-plus@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz" - }, - "async": { - "version": "2.1.2", - "from": "async@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.1.2.tgz" - }, - "cliui": { - "version": "3.2.0", - "from": "cliui@>=3.0.3 <4.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz" - }, - "form-data": { - "version": "1.0.1", - "from": "form-data@>=1.0.0-rc3 <1.1.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.1.tgz" - }, - "http-signature": { - "version": "0.11.0", - "from": "http-signature@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-0.11.0.tgz" - }, - "lodash": { - "version": "4.16.4", - "from": "lodash@>=4.14.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" - }, - "qs": { - "version": "5.2.1", - "from": "qs@>=5.2.0 <5.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.1.tgz" - }, - "request": { - "version": "2.65.0", - "from": "request@2.65.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.65.0.tgz" - }, - "tough-cookie": { - "version": "2.2.2", - "from": "tough-cookie@>=2.2.0 <2.3.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.2.2.tgz" - }, - "window-size": { - "version": "0.1.4", - "from": "window-size@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz" - }, "yargs": { "version": "3.29.0", "from": "yargs@3.29.0", @@ -3114,16 +3101,9 @@ "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz" }, "loose-envify": { - "version": "1.2.0", + "version": "1.3.0", "from": "loose-envify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.2.0.tgz", - "dependencies": { - "js-tokens": { - "version": "1.0.3", - "from": "js-tokens@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.3.tgz" - } - } + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.0.tgz" }, "loud-rejection": { "version": "1.6.0", @@ -3179,7 +3159,7 @@ }, "micromatch": { "version": "2.3.11", - "from": "micromatch@>=2.3.11 <3.0.0", + "from": "micromatch@2.3.11", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz" }, "mime": { @@ -3203,14 +3183,14 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" }, "minimist": { - "version": "0.0.8", - "from": "minimist@0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + "version": "0.0.10", + "from": "minimist@>=0.0.1 <0.1.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" }, "mkdirp": { - "version": "0.5.1", - "from": "mkdirp@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + "version": "0.3.0", + "from": "mkdirp@0.3.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" }, "moment": { "version": "2.15.2", @@ -3234,7 +3214,7 @@ }, "nan": { "version": "2.4.0", - "from": "nan@>=2.1.0 <3.0.0", + "from": "nan@>=2.3.0 <3.0.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.4.0.tgz" }, "negotiator": { @@ -3266,12 +3246,12 @@ }, "node-uuid": { "version": "1.4.7", - "from": "node-uuid@>=1.4.7 <1.5.0", + "from": "node-uuid@>=1.4.3 <1.5.0", "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.7.tgz" }, "nopt": { "version": "3.0.6", - "from": "nopt@>=3.0.0 <4.0.0", + "from": "nopt@>=3.0.0 <3.1.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz" }, "normalize-package-data": { @@ -3328,7 +3308,7 @@ }, "oauth-sign": { "version": "0.8.2", - "from": "oauth-sign@>=0.8.1 <0.9.0", + "from": "oauth-sign@>=0.8.0 <0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz" }, "object-assign": { @@ -3363,7 +3343,7 @@ }, "once": { "version": "1.4.0", - "from": "once@>=1.0.0 <2.0.0", + "from": "once@>=1.3.0 <2.0.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz" }, "open": { @@ -3384,19 +3364,19 @@ "optimist": { "version": "0.6.1", "from": "optimist@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "dependencies": { - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz" }, "optionator": { "version": "0.8.2", "from": "optionator@>=0.8.1 <0.9.0", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz" + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "dependencies": { + "wordwrap": { + "version": "1.0.0", + "from": "wordwrap@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" + } + } }, "options": { "version": "0.0.6", @@ -3520,30 +3500,20 @@ "from": "phantomjs-prebuilt@>=2.1.12 <3.0.0", "resolved": "https://registry.npmjs.org/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.13.tgz", "dependencies": { - "async": { - "version": "2.1.2", - "from": "async@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-2.1.2.tgz" + "assert-plus": { + "version": "0.2.0", + "from": "assert-plus@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" }, "bl": { "version": "1.1.2", "from": "bl@>=1.1.2 <1.2.0", "resolved": "https://registry.npmjs.org/bl/-/bl-1.1.2.tgz" }, - "form-data": { - "version": "1.0.1", - "from": "form-data@>=1.0.0-rc4 <1.1.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-1.0.1.tgz" - }, - "lodash": { - "version": "4.16.4", - "from": "lodash@>=4.14.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" - }, - "qs": { - "version": "6.2.1", - "from": "qs@>=6.2.0 <6.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.1.tgz" + "http-signature": { + "version": "1.1.1", + "from": "http-signature@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz" }, "readable-stream": { "version": "2.0.6", @@ -3554,6 +3524,11 @@ "version": "2.74.0", "from": "request@>=2.74.0 <2.75.0", "resolved": "https://registry.npmjs.org/request/-/request-2.74.0.tgz" + }, + "tough-cookie": { + "version": "2.3.2", + "from": "tough-cookie@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz" } } }, @@ -3723,9 +3698,9 @@ "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.1.5.tgz" }, "qs": { - "version": "6.3.0", - "from": "qs@>=6.3.0 <6.4.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.3.0.tgz" + "version": "6.2.1", + "from": "qs@6.2.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.1.tgz" }, "querystring": { "version": "0.2.0", @@ -3881,9 +3856,16 @@ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz" }, "request": { - "version": "2.76.0", - "from": "request@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.76.0.tgz" + "version": "2.65.0", + "from": "request@2.65.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.65.0.tgz", + "dependencies": { + "qs": { + "version": "5.2.1", + "from": "qs@>=5.2.0 <5.3.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.1.tgz" + } + } }, "request-progress": { "version": "2.0.1", @@ -3933,14 +3915,7 @@ "rimraf": { "version": "2.5.4", "from": "rimraf@>=2.2.8 <3.0.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.5 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - } - } + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz" }, "ripemd160": { "version": "0.2.0", @@ -3967,6 +3942,11 @@ "from": "async@1.4.0", "resolved": "https://registry.npmjs.org/async/-/async-1.4.0.tgz" }, + "glob": { + "version": "5.0.15", + "from": "glob@>=5.0.14 <6.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" + }, "rimraf": { "version": "2.4.3", "from": "rimraf@2.4.3", @@ -3986,7 +3966,7 @@ }, "semver": { "version": "5.3.0", - "from": "semver@>=5.3.0 <6.0.0", + "from": "semver@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0||>=4.0.0 <5.0.0||>=5.0.0 <6.0.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz" }, "send": { @@ -4147,9 +4127,9 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.3.0.tgz" }, "source-map-support": { - "version": "0.4.5", + "version": "0.4.6", "from": "source-map-support@>=0.4.2 <0.5.0", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.5.tgz", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.6.tgz", "dependencies": { "source-map": { "version": "0.5.6", @@ -4188,6 +4168,11 @@ "from": "sshpk@>=1.7.0 <2.0.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.10.1.tgz", "dependencies": { + "asn1": { + "version": "0.2.3", + "from": "asn1@>=0.2.3 <0.3.0", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz" + }, "assert-plus": { "version": "1.0.0", "from": "assert-plus@>=1.0.0 <2.0.0", @@ -4335,9 +4320,9 @@ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz" }, "timezone-js": { - "version": "0.4.11", - "from": "leigh-johnson/timezone-js#0.4.13", - "resolved": "git://github.com/leigh-johnson/timezone-js.git#3b1de3f89106706483e79831312d3c325f95dd8a" + "version": "0.4.14", + "from": "leigh-johnson/timezone-js#0.4.14", + "resolved": "git://github.com/leigh-johnson/timezone-js.git#6937de14ce0c193961538bb5b3b12b7ef62a358f" }, "tiny-lr": { "version": "0.2.1", @@ -4367,9 +4352,9 @@ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.2.tgz" }, "tough-cookie": { - "version": "2.3.2", - "from": "tough-cookie@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz" + "version": "2.2.2", + "from": "tough-cookie@>=2.2.0 <2.3.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.2.2.tgz" }, "trim-newlines": { "version": "1.0.0", @@ -4421,10 +4406,30 @@ "from": "async@>=0.2.6 <0.3.0", "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" }, + "cliui": { + "version": "2.1.0", + "from": "cliui@>=2.1.0 <3.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz" + }, "source-map": { "version": "0.5.6", "from": "source-map@>=0.5.1 <0.6.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" + }, + "window-size": { + "version": "0.1.0", + "from": "window-size@0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" + }, + "wordwrap": { + "version": "0.0.2", + "from": "wordwrap@0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" + }, + "yargs": { + "version": "3.10.0", + "from": "yargs@>=3.10.0 <3.11.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz" } } }, @@ -4466,9 +4471,9 @@ } }, "url-parse": { - "version": "1.1.6", + "version": "1.1.7", "from": "url-parse@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.1.6.tgz" + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.1.7.tgz" }, "useragent": { "version": "2.1.9", @@ -4554,6 +4559,11 @@ "from": "wd@>=1.0.0 <2.0.0", "resolved": "https://registry.npmjs.org/wd/-/wd-1.0.0.tgz", "dependencies": { + "assert-plus": { + "version": "0.2.0", + "from": "assert-plus@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz" + }, "async": { "version": "2.0.1", "from": "async@2.0.1", @@ -4569,16 +4579,16 @@ "from": "form-data@>=2.0.0 <2.1.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.0.0.tgz" }, + "http-signature": { + "version": "1.1.1", + "from": "http-signature@>=1.1.0 <1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz" + }, "lodash": { "version": "4.16.2", "from": "lodash@4.16.2", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.2.tgz" }, - "qs": { - "version": "6.2.1", - "from": "qs@>=6.2.0 <6.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.1.tgz" - }, "readable-stream": { "version": "2.0.6", "from": "readable-stream@>=2.0.5 <2.1.0", @@ -4589,6 +4599,11 @@ "from": "request@2.75.0", "resolved": "https://registry.npmjs.org/request/-/request-2.75.0.tgz" }, + "tough-cookie": { + "version": "2.3.2", + "from": "tough-cookie@>=2.3.0 <2.4.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz" + }, "underscore.string": { "version": "3.3.4", "from": "underscore.string@3.3.4", @@ -4610,7 +4625,7 @@ }, "webpack-dev-middleware": { "version": "1.8.4", - "from": "webpack-dev-middleware@>=1.0.11 <2.0.0", + "from": "webpack-dev-middleware@>=1.4.0 <2.0.0", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-1.8.4.tgz", "dependencies": { "mime": { @@ -4620,6 +4635,28 @@ } } }, + "webpack-dev-server": { + "version": "1.16.2", + "from": "webpack-dev-server@>=1.14.1 <2.0.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-1.16.2.tgz", + "dependencies": { + "express": { + "version": "4.14.0", + "from": "express@>=4.13.3 <5.0.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.14.0.tgz" + }, + "qs": { + "version": "6.2.0", + "from": "qs@6.2.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.2.0.tgz" + }, + "supports-color": { + "version": "3.1.2", + "from": "supports-color@>=3.1.1 <4.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" + } + } + }, "websocket-driver": { "version": "0.6.5", "from": "websocket-driver@>=0.5.1", @@ -4646,14 +4683,14 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz" }, "window-size": { - "version": "0.1.0", - "from": "window-size@0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" + "version": "0.1.4", + "from": "window-size@>=0.1.2 <0.2.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz" }, "wordwrap": { - "version": "1.0.0", - "from": "wordwrap@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" + "version": "0.0.3", + "from": "wordwrap@>=0.0.2 <0.1.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" }, "wrap-ansi": { "version": "2.0.0", @@ -4696,9 +4733,16 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz" }, "yargs": { - "version": "3.10.0", - "from": "yargs@>=3.10.0 <3.11.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz" + "version": "6.0.0", + "from": "yargs@6.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-6.0.0.tgz", + "dependencies": { + "window-size": { + "version": "0.2.0", + "from": "window-size@>=0.2.0 <0.3.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz" + } + } }, "yargs-parser": { "version": "4.0.2", @@ -4728,9 +4772,9 @@ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.1.0.tgz", "dependencies": { "lodash": { - "version": "4.16.4", + "version": "4.16.5", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.4.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.5.tgz" } } } diff --git a/awx/ui/package.json b/awx/ui/package.json index 3383594e82..2836c5d754 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -13,7 +13,7 @@ }, "engines": { "node": "^6.3.1", - "npm": "^3.10.3" + "npm": "3.10.7" }, "scripts": { "build-docker-machine": "grunt dev; ip=$(docker-machine ip $DOCKER_MACHINE_NAME); npm set ansible-tower:django_host ${ip}; grunt sync", @@ -27,6 +27,12 @@ "test:ci": "npm run test -- --single-run --reporter junit,dots --browsers=PhantomJS", "test:saucelabs": "SAUCE_USERNAME=${npm_package_config_sauce_username} SAUCE_ACCESS_KEY=${npm_package_config_sauce_access_key} karma start karma.conf-saucelabs.js" }, + "optionalDependencies": { + "browser-sync": "^2.14.0", + "grunt-browser-sync": "^2.2.0", + "grunt-contrib-watch": "^1.0.0", + "webpack-dev-server": "^1.14.1" + }, "devDependencies": { "angular-mocks": "^1.5.8", "babel-core": "^6.11.4", @@ -34,18 +40,15 @@ "babel-loader": "^6.2.4", "babel-plugin-istanbul": "^2.0.0", "babel-preset-es2015": "^6.9.0", - "browser-sync": "^2.14.0", "expose-loader": "^0.7.1", "grunt": "^1.0.1", "grunt-angular-gettext": "^2.2.3", - "grunt-browser-sync": "^2.2.0", "grunt-cli": "^1.2.0", "grunt-concurrent": "^2.3.0", "grunt-contrib-clean": "^1.0.0", "grunt-contrib-concat": "^1.0.1", "grunt-contrib-copy": "^1.0.0", "grunt-contrib-less": "^1.3.0", - "grunt-contrib-watch": "^1.0.0", "grunt-extract-sourcemap": "^0.1.18", "grunt-newer": "^1.2.0", "grunt-webpack": "^1.0.11", @@ -70,8 +73,7 @@ "load-grunt-tasks": "^3.5.0", "phantomjs-prebuilt": "^2.1.12", "time-grunt": "^1.4.0", - "webpack": "^1.13.1", - "webpack-dev-server": "^1.14.1" + "webpack": "^1.13.1" }, "dependencies": { "angular": "~1.4.7", @@ -85,14 +87,13 @@ "angular-resource": "^1.4.3", "angular-sanitize": "^1.4.3", "angular-scheduler": "chouseknecht/angular-scheduler#0.1.0", - "angular-tz-extensions": "chouseknecht/angular-tz-extensions#0.3.11", + "angular-tz-extensions": "chouseknecht/angular-tz-extensions#0.3.12", "angular-ui-router": "^1.0.0-beta.3", "bootstrap": "^3.1.1", "bootstrap-datepicker": "^1.4.0", "codemirror": "^5.17.0", "components-font-awesome": "^4.6.1", "d3": "^3.3.13", - "fs": "0.0.2", "javascript-detect-element-resize": "^0.5.3", "jquery": "2.2.4", "jquery-ui": "1.10.5",
    {{ $index + ((" + list.iterator + "_page - 1) * " + list.iterator + "_page_size) + 1 }}.
    "; + innerTable += "
    "; - for (field_action in list.fieldActions) { - if (field_action !== 'columnClass') { - if (list.fieldActions[field_action].type && list.fieldActions[field_action].type === 'DropDown') { - innerTable += DropDown({ - list: list, - fld: field_action, - options: options, - base: base, - type: 'fieldActions', - td: false + for (field_action in list.fieldActions) { + if (field_action !== 'columnClass') { + if (list.fieldActions[field_action].type && list.fieldActions[field_action].type === 'DropDown') { + innerTable += DropDown({ + list: list, + fld: field_action, + options: options, + base: base, + type: 'fieldActions', + td: false + }); + } else { + fAction = list.fieldActions[field_action]; + innerTable += ""; } + //html += (fAction.label) ? " " + list.fieldActions[field_action].label + + // "" : ""; + innerTable += ""; } } - innerTable += "
    " + - loading + "
    ') - .addClass('col-xs-1 select-column List-tableHeader List-staticColumn--smallStatus') - .append( - $('') - .attr('selections-empty', 'selectedItems.length === 0') - .attr('items-length', list.name + '.length') - .attr('label', '')); - } - - if (options === undefined) { - options = this.options; - } - - html = "
    #"; - } - html += "Select"; - html += (list.fieldActions.label === undefined || list.fieldActions.label) ? i18n._("Actions") : ""; - html += "
    ') + .addClass('col-xs-1 select-column List-tableHeader List-staticColumn--smallStatus') + .append( + $('') + .attr('selections-empty', 'selectedItems.length === 0') + .attr('items-length', list.name + '.length') + .attr('label', '')); + } + + if (options === undefined) { + options = this.options; + } + + html = "
    # + + Select"; + html += (list.fieldActions.label === undefined || list.fieldActions.label) ? i18n._("Actions") : ""; + html += "