From f5c10f99b0912e289b8dd132446af906a5e52ab3 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 27 Sep 2018 15:47:51 -0400 Subject: [PATCH 01/99] support workflow convergence nodes * remove convergence restriction in API * change task manager logic to be aware of and support convergence nodes --- awx/api/views/__init__.py | 2 - .../0050_v331_workflow_convergence.py | 20 +++++++ awx/main/models/workflow.py | 3 + awx/main/scheduler/dag_simple.py | 2 +- awx/main/scheduler/dag_workflow.py | 48 ++++++++++++++-- awx/main/scheduler/task_manager.py | 2 + .../tests/functional/models/test_workflow.py | 55 ++++++++++++++++++- 7 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 awx/main/migrations/0050_v331_workflow_convergence.py diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 87140b02b1..fc0992c958 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -2969,8 +2969,6 @@ class WorkflowJobTemplateNodeChildrenBaseList(WorkflowsEnforcementMixin, Enforce if not find: sub_node = graph[sub.pk] parent_node = graph[parent.pk] - if sub_node['metadata']['parent'] is not None: - return {"Error": _("Multiple parent relationship not allowed.")} sub_node['metadata']['parent'] = parent_node iter_node = sub_node while iter_node is not None: diff --git a/awx/main/migrations/0050_v331_workflow_convergence.py b/awx/main/migrations/0050_v331_workflow_convergence.py new file mode 100644 index 0000000000..2e6edd42d7 --- /dev/null +++ b/awx/main/migrations/0050_v331_workflow_convergence.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-09-28 14:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0049_v330_validate_instance_capacity_adjustment'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobnode', + name='do_not_run', + field=models.BooleanField(default=False), + ), + ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2be55d2992..946812350b 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -184,6 +184,9 @@ class WorkflowJobNode(WorkflowNodeBase): default={}, editable=False, ) + do_not_run = models.BooleanField( + default=False + ) def get_absolute_url(self, request=None): return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index c7bde94101..0a078f9821 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -51,7 +51,7 @@ class SimpleDAG(object): for n in self.nodes: doc += "%s [color = %s]\n" % ( short_string_obj(n['node_object']), - "red" if n['node_object'].status == 'running' else "black", + "red" if getattr(n['node_object'], 'status', 'N/A') == 'running' else "black", ) for from_node, to_node, label in self.edges: doc += "%s -> %s [ label=\"%s\" ];\n" % ( diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 924e459929..b49cf185e9 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,4 +1,7 @@ +# Python +import copy + # AWX from awx.main.scheduler.dag_simple import SimpleDAG @@ -30,19 +33,19 @@ class WorkflowDAG(SimpleDAG): obj = n['node_object'] job = obj.job - if not job: + if not job and obj.do_not_run is False: nodes_found.append(n) # Job is about to run or is running. Hold our horses and wait for # the job to finish. We can't proceed down the graph path until we # have the job result. - elif job.status not in ['failed', 'successful']: + elif job and job.status not in ['failed', 'successful']: continue - elif job.status == 'failed': + elif job and job.status == 'failed': children_failed = self.get_dependencies(obj, 'failure_nodes') children_always = self.get_dependencies(obj, 'always_nodes') children_all = children_failed + children_always nodes.extend(children_all) - elif job.status == 'successful': + elif job and job.status == 'successful': children_success = self.get_dependencies(obj, 'success_nodes') children_always = self.get_dependencies(obj, 'always_nodes') children_all = children_success + children_always @@ -100,3 +103,40 @@ class WorkflowDAG(SimpleDAG): # have the job result. return False, False return True, is_failed + + def mark_dnr_nodes(self): + root_nodes = self.get_root_nodes() + nodes = copy.copy(root_nodes) + nodes_marked_do_not_run = [] + + for index, n in enumerate(nodes): + obj = n['node_object'] + job = obj.job + + if not job and obj.do_not_run is False and n not in root_nodes: + parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] + all_parents_dnr = True + for p in parent_nodes: + if not p.job and p.do_not_run is False: + all_parents_dnr = False + break + #all_parents_dnr = reduce(lambda p: bool(p.do_not_run == True), parent_nodes) + if all_parents_dnr: + obj.do_not_run = True + nodes_marked_do_not_run.append(n) + + if obj.do_not_run: + children_success = self.get_dependencies(obj, 'success_nodes') + children_failed = self.get_dependencies(obj, 'failure_nodes') + children_always = self.get_dependencies(obj, 'always_nodes') + children_all = children_failed + children_always + nodes.extend(children_all) + elif job and job.status == 'failed': + children_failed = self.get_dependencies(obj, 'success_nodes') + children_all = children_failed + nodes.extend(children_all) + elif job and job.status == 'successful': + children_success = self.get_dependencies(obj, 'failure_nodes') + children_all = children_success + nodes.extend(children_all) + return [n['node_object'] for n in nodes_marked_do_not_run] diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 2854b3ab34..16aa99ee08 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -174,6 +174,8 @@ class TaskManager(): else: is_done, has_failed = dag.is_workflow_done() if not is_done: + workflow_nodes = dag.mark_dnr_nodes() + map(lambda n: n.save(update_fields=['do_not_run']), workflow_nodes) continue logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful') result.append(workflow_job.id) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index d7d03a53bb..cc44a9b621 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -100,6 +100,59 @@ class TestWorkflowDAGFunctional(TransactionTestCase): self.assertFalse(has_failed) +@pytest.mark.django_db +class TestWorkflowDNR(): + 'success', 'new' + + @pytest.fixture + def workflow_job_fn(self): + def fn(states=['new', 'new', 'new', 'new', 'new', 'new']): + """ + Workflow topology: + node[0] + /\ + s/ \f + / \ + node[1] node[3] + / \ + s/ \f + / \ + node[2] node[4] + \ / + \ / + \ / + s f + \ / + \ / + node[5] + """ + wfj = WorkflowJob.objects.create() + jt = JobTemplate.objects.create(name='test-jt') + nodes = [WorkflowJobNode.objects.create(workflow_job=wfj, unified_job_template=jt) for i in range(0, 6)] + for node, state in zip(nodes, states): + if state: + node.job = jt.create_job() + node.job.status = state + node.job.save() + node.save() + nodes[0].success_nodes.add(nodes[1]) + nodes[1].success_nodes.add(nodes[2]) + nodes[0].failure_nodes.add(nodes[3]) + nodes[3].failure_nodes.add(nodes[4]) + nodes[2].success_nodes.add(nodes[5]) + nodes[4].failure_nodes.add(nodes[5]) + return wfj, nodes + return fn + + def test_workflow_dnr_because_parent(self, workflow_job_fn): + wfj, nodes = workflow_job_fn(states=['successful', None, None, None, None, None,]) + dag = WorkflowDAG(workflow_job=wfj) + workflow_nodes = dag.mark_dnr_nodes() + assert 2 == len(workflow_nodes) + assert nodes[3] in workflow_nodes + assert nodes[4] in workflow_nodes + + @pytest.mark.django_db class TestWorkflowJob: @pytest.fixture @@ -193,8 +246,6 @@ class TestWorkflowJobTemplate: nodes[2].always_nodes.add(node_assoc) # test cycle validation assert test_view.is_valid_relation(node_assoc, nodes[0]) == {'Error': 'Cycle detected.'} - # test multi-ancestor validation - assert test_view.is_valid_relation(node_assoc, nodes[1]) == {'Error': 'Multiple parent relationship not allowed.'} # test mutex validation test_view.relationship = 'failure_nodes' From a9365a3967f16886bd9b450109f39a40bb0d0965 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 13:40:54 -0400 Subject: [PATCH 02/99] code cleanup --- awx/main/scheduler/dag_workflow.py | 7 +++---- awx/main/tests/functional/models/test_workflow.py | 2 -- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index b49cf185e9..510e87cf44 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -133,10 +133,9 @@ class WorkflowDAG(SimpleDAG): nodes.extend(children_all) elif job and job.status == 'failed': children_failed = self.get_dependencies(obj, 'success_nodes') - children_all = children_failed - nodes.extend(children_all) + nodes.extend(children_failed) elif job and job.status == 'successful': children_success = self.get_dependencies(obj, 'failure_nodes') - children_all = children_success - nodes.extend(children_all) + nodes.extend(children_success) return [n['node_object'] for n in nodes_marked_do_not_run] + diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index cc44a9b621..f406085aca 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -102,8 +102,6 @@ class TestWorkflowDAGFunctional(TransactionTestCase): @pytest.mark.django_db class TestWorkflowDNR(): - 'success', 'new' - @pytest.fixture def workflow_job_fn(self): def fn(states=['new', 'new', 'new', 'new', 'new', 'new']): From 447dfbb64dd92bcdaab9797851192a4488a3d672 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 13:49:05 -0400 Subject: [PATCH 03/99] only visit nodes once for dnr --- awx/main/scheduler/dag_workflow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 510e87cf44..c872e3a712 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -108,9 +108,13 @@ class WorkflowDAG(SimpleDAG): root_nodes = self.get_root_nodes() nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] + node_ids_visited = set() for index, n in enumerate(nodes): obj = n['node_object'] + if obj.id in node_ids_visited: + continue + node_ids_visited.add(obj.id) job = obj.job if not job and obj.do_not_run is False and n not in root_nodes: From 779e1a34dbf066ccc573fe660015282e70c1dedf Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 15:06:11 -0400 Subject: [PATCH 04/99] remove dnr field from jt wf node --- awx/api/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index eba6bbcf4e..7bd09f56af 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3916,7 +3916,8 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): class Meta: model = WorkflowJobNode fields = ('*', 'credential', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related', - 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes',) + 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', + 'do_not_run',) def get_related(self, obj): res = super(WorkflowJobNodeSerializer, self).get_related(obj) From ad56a27cc04a5588a683195a86d3ac84d12f2878 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 16:36:27 -0400 Subject: [PATCH 05/99] mark dnr field read only --- awx/api/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 7bd09f56af..a7bec95898 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3918,6 +3918,7 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): fields = ('*', 'credential', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'do_not_run',) + read_only_fields = ('do_not_run') def get_related(self, obj): res = super(WorkflowJobNodeSerializer, self).get_related(obj) From cc374ca705ca42525da1a9f8ced58ff0569131c3 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 16:36:50 -0400 Subject: [PATCH 06/99] update debug dot graph to output dnr data --- awx/main/scheduler/dag_simple.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 0a078f9821..3456748f76 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -28,21 +28,13 @@ class SimpleDAG(object): return self.nodes.__iter__() def generate_graphviz_plot(self): - def short_string_obj(obj): - if type(obj) == Job: - type_str = "Job" - if type(obj) == AdHocCommand: - type_str = "AdHocCommand" - elif type(obj) == InventoryUpdate: - type_str = "Inventory" - elif type(obj) == ProjectUpdate: - type_str = "Project" - elif type(obj) == WorkflowJob: - type_str = "Workflow" + def run_status(obj): + if obj.do_not_run is True: + s = "DNR" else: - type_str = "Unknown" - type_str += "%s" % str(obj.id) - return type_str + s = "RUN" + s += "_{}".format(obj.id) + return s doc = """ digraph g { @@ -50,13 +42,13 @@ class SimpleDAG(object): """ for n in self.nodes: doc += "%s [color = %s]\n" % ( - short_string_obj(n['node_object']), + run_status(n['node_object']), "red" if getattr(n['node_object'], 'status', 'N/A') == 'running' else "black", ) for from_node, to_node, label in self.edges: doc += "%s -> %s [ label=\"%s\" ];\n" % ( - short_string_obj(self.nodes[from_node]['node_object']), - short_string_obj(self.nodes[to_node]['node_object']), + run_status(self.nodes[from_node]['node_object']), + run_status(self.nodes[to_node]['node_object']), label, ) doc += "}\n" From 3506b9a7d89c3389c21600cc20d6347565393015 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 16:38:54 -0400 Subject: [PATCH 07/99] Revert "mark dnr field read only" This reverts commit 3dbc52d91223167683fd01174222bd6c22813dbd. Workflow Job Nodes are read only already --- awx/api/serializers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a7bec95898..7bd09f56af 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3918,7 +3918,6 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): fields = ('*', 'credential', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related', 'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes', 'do_not_run',) - read_only_fields = ('do_not_run') def get_related(self, obj): res = super(WorkflowJobNodeSerializer, self).get_related(obj) From ebabec0dade7d7e762725bacee1b93ff155a0d5f Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 11 Oct 2018 16:48:50 -0400 Subject: [PATCH 08/99] always find and mark dnr nodes --- awx/main/scheduler/task_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 16aa99ee08..f1d447077e 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -173,9 +173,9 @@ class TaskManager(): status_changed = True else: is_done, has_failed = dag.is_workflow_done() + workflow_nodes = dag.mark_dnr_nodes() + map(lambda n: n.save(update_fields=['do_not_run']), workflow_nodes) if not is_done: - workflow_nodes = dag.mark_dnr_nodes() - map(lambda n: n.save(update_fields=['do_not_run']), workflow_nodes) continue logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful') result.append(workflow_job.id) From 1a064bdc598477d806fe8b0b6f9213971a6d68a7 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 12 Oct 2018 10:49:13 -0400 Subject: [PATCH 09/99] satisfy flake8 --- awx/main/scheduler/dag_simple.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 3456748f76..d436c0c194 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,12 +1,4 @@ -from awx.main.models import ( - Job, - AdHocCommand, - InventoryUpdate, - ProjectUpdate, - WorkflowJob, -) - class SimpleDAG(object): ''' A simple implementation of a directed acyclic graph ''' From ff6db37a956022ec82e38be461be5d3e8a7a3472 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 15 Oct 2018 09:48:00 -0400 Subject: [PATCH 10/99] correct stop DNR propogation * If a child has a parent that is not in the finished state then do not propogate the DNR to the child in question. * If a parent is in a finished state; do not propogate the DNR to the child if the path to the child is traversed (based on the parent job status). --- awx/main/scheduler/dag_simple.py | 24 +++++++++++++++++------- awx/main/scheduler/dag_workflow.py | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index d436c0c194..c78e76482d 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -21,21 +21,31 @@ class SimpleDAG(object): def generate_graphviz_plot(self): def run_status(obj): + dnr = "RUN" + status = "NA" + if obj.job: + status = obj.job.status if obj.do_not_run is True: - s = "DNR" - else: - s = "RUN" - s += "_{}".format(obj.id) - return s + dnr = "DNR" + return "{}_{}_{}".format(dnr, status, obj.id) doc = """ digraph g { rankdir = LR """ for n in self.nodes: + obj = n['node_object'] + status = "NA" + if obj.job: + status = obj.job.status + color = 'black' + if status == 'successful': + color = 'green' + elif status == 'failed': + color = 'red' doc += "%s [color = %s]\n" % ( run_status(n['node_object']), - "red" if getattr(n['node_object'], 'status', 'N/A') == 'running' else "black", + color ) for from_node, to_node, label in self.edges: doc += "%s -> %s [ label=\"%s\" ];\n" % ( @@ -44,7 +54,7 @@ class SimpleDAG(object): label, ) doc += "}\n" - gv_file = open('/tmp/graph.gv', 'w') + gv_file = open('/awx_devel/graph.gv', 'w') gv_file.write(doc) gv_file.close() diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index c872e3a712..dd9e5b7bb5 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -120,15 +120,30 @@ class WorkflowDAG(SimpleDAG): if not job and obj.do_not_run is False and n not in root_nodes: parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] all_parents_dnr = True + parent_run_path = False for p in parent_nodes: if not p.job and p.do_not_run is False: all_parents_dnr = False - break + + elif p.job and p.job.status in ['new', 'pending', 'waiting', 'running']: + parent_run_path = True + + elif p.job and p.job.status == 'successful': + if n in self.get_dependencies(p, 'success_nodes'): + parent_run_path = True + + elif p.job and p.job.status == 'failed': + children_failed = self.get_dependencies(p, 'failure_nodes') + children_always = self.get_dependencies(p, 'always_nodes') + if n in children_always or n in children_failed: + parent_run_path = True + #all_parents_dnr = reduce(lambda p: bool(p.do_not_run == True), parent_nodes) - if all_parents_dnr: + if all_parents_dnr and parent_run_path is False: obj.do_not_run = True nodes_marked_do_not_run.append(n) + if obj.do_not_run: children_success = self.get_dependencies(obj, 'success_nodes') children_failed = self.get_dependencies(obj, 'failure_nodes') From b4fc58549505f199776eecf8364018ab471b8acf Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 15 Oct 2018 13:57:19 -0400 Subject: [PATCH 11/99] stop DNR propogation on always path * This makes sure DNR propogation stops when a job is successful, down an always path --- awx/main/scheduler/dag_workflow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index dd9e5b7bb5..3eebfb1b4f 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -129,7 +129,9 @@ class WorkflowDAG(SimpleDAG): parent_run_path = True elif p.job and p.job.status == 'successful': - if n in self.get_dependencies(p, 'success_nodes'): + children_success = self.get_dependencies(p, 'success_nodes') + children_always = self.get_dependencies(p, 'always_nodes') + if n in children_success or n in children_always: parent_run_path = True elif p.job and p.job.status == 'failed': From 77661c603271d1290aaaed0c97c9dad92233e190 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 16 Oct 2018 08:33:20 -0400 Subject: [PATCH 12/99] short circuit performance optimization --- awx/main/scheduler/dag_workflow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 3eebfb1b4f..cc2ea73da0 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -122,6 +122,9 @@ class WorkflowDAG(SimpleDAG): all_parents_dnr = True parent_run_path = False for p in parent_nodes: + if p.do_not_run is True: + continue + if not p.job and p.do_not_run is False: all_parents_dnr = False From 914892c3ace6f3454fbfe87afa7e20aac738ae83 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 17 Oct 2018 10:16:40 -0400 Subject: [PATCH 13/99] all parents should finish before start child --- awx/main/scheduler/dag_workflow.py | 56 ++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index cc2ea73da0..f1884a682b 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -24,32 +24,50 @@ class WorkflowDAG(SimpleDAG): for related_node in related_nodes: self.add_edge(workflow_node, related_node, node_type) + ''' + Determine if all, relevant, parents node are finished. + Relevant parents are parents that are marked do_not_run False. + + :param node: a node entry from SimpleDag.nodes (i.e. a dict with property ['node_object'] + + Return a boolean + ''' + def are_relevant_parents_finished(self, node): + obj = node['node_object'] + parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] + for p in parent_nodes: + if p.do_not_run is True: + continue + + # Node might run a job + if p.do_not_run is False and not p.job: + return False + + # Node decidedly got a job; check if job is done + if p.job and p.job.status not in ['successful', 'failed']: + return False + return True + def bfs_nodes_to_run(self): - root_nodes = self.get_root_nodes() - nodes = root_nodes + nodes = self.get_root_nodes() nodes_found = [] for index, n in enumerate(nodes): obj = n['node_object'] - job = obj.job - if not job and obj.do_not_run is False: - nodes_found.append(n) - # Job is about to run or is running. Hold our horses and wait for - # the job to finish. We can't proceed down the graph path until we - # have the job result. - elif job and job.status not in ['failed', 'successful']: + if obj.do_not_run is True: continue - elif job and job.status == 'failed': - children_failed = self.get_dependencies(obj, 'failure_nodes') - children_always = self.get_dependencies(obj, 'always_nodes') - children_all = children_failed + children_always - nodes.extend(children_all) - elif job and job.status == 'successful': - children_success = self.get_dependencies(obj, 'success_nodes') - children_always = self.get_dependencies(obj, 'always_nodes') - children_all = children_success + children_always - nodes.extend(children_all) + + if obj.job: + if obj.job.status == 'failed': + nodes.extend(self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) + elif obj.job.status == 'successful': + nodes.extend(self.get_dependencies(obj, 'success_nodes') + + self.get_dependencies(obj, 'always_nodes')) + else: + if self.are_relevant_parents_finished(n): + nodes_found.append(n) return [n['node_object'] for n in nodes_found] def cancel_node_jobs(self): From 9bf2a49e0f25353d8e41ba03547afb2457263cd2 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 17 Oct 2018 12:49:15 -0400 Subject: [PATCH 14/99] save state --- awx/main/scheduler/dag_workflow.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index f1884a682b..c94de17280 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -135,7 +135,7 @@ class WorkflowDAG(SimpleDAG): node_ids_visited.add(obj.id) job = obj.job - if not job and obj.do_not_run is False and n not in root_nodes: + if obj.do_not_run is False and not job and n not in root_nodes: parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] all_parents_dnr = True parent_run_path = False @@ -165,19 +165,14 @@ class WorkflowDAG(SimpleDAG): if all_parents_dnr and parent_run_path is False: obj.do_not_run = True nodes_marked_do_not_run.append(n) - - - if obj.do_not_run: - children_success = self.get_dependencies(obj, 'success_nodes') - children_failed = self.get_dependencies(obj, 'failure_nodes') - children_always = self.get_dependencies(obj, 'always_nodes') - children_all = children_failed + children_always - nodes.extend(children_all) - elif job and job.status == 'failed': - children_failed = self.get_dependencies(obj, 'success_nodes') - nodes.extend(children_failed) - elif job and job.status == 'successful': - children_success = self.get_dependencies(obj, 'failure_nodes') - nodes.extend(children_success) + elif obj.do_not_run is True: + nodes.extend(self.get_dependencies(obj, 'success_nodes') + + self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) + elif job: + if job.status == 'failed': + nodes.extend(self.get_dependencies(obj, 'success_nodes')) + elif job.status == 'successful': + nodes.extend(self.get_dependencies(obj, 'failure_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From 6ef6b649e831d625d3a2aae3da560f50399dce78 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 17 Oct 2018 15:39:55 -0400 Subject: [PATCH 15/99] cleaner code --- awx/main/scheduler/dag_workflow.py | 93 ++++++++++++++++++------------ 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index c94de17280..e5c3243a58 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -32,7 +32,7 @@ class WorkflowDAG(SimpleDAG): Return a boolean ''' - def are_relevant_parents_finished(self, node): + def _are_relevant_parents_finished(self, node): obj = node['node_object'] parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] for p in parent_nodes: @@ -66,7 +66,7 @@ class WorkflowDAG(SimpleDAG): nodes.extend(self.get_dependencies(obj, 'success_nodes') + self.get_dependencies(obj, 'always_nodes')) else: - if self.are_relevant_parents_finished(n): + if self._are_relevant_parents_finished(n): nodes_found.append(n) return [n['node_object'] for n in nodes_found] @@ -122,6 +122,50 @@ class WorkflowDAG(SimpleDAG): return False, False return True, is_failed + ''' + Determine if all nodes have been decided on being marked do_not_run. + Nodes that are do_not_run False may become do_not_run True in the future. + We know a do_not_run False node will NOT be marked do_not_run True if there + is a job run for that node. + + :param workflow_nodes: list of workflow_nodes + + Return a boolean + ''' + def _are_all_nodes_dnr_decided(self, workflow_nodes): + for n in workflow_nodes: + if n.do_not_run is False and not n.job: + return False + return True + #return not any((n.do_not_run is False and not n.job) for n in workflow_nodes) + + + ''' + Determine if a node (1) is ready to be marked do_not_run and (2) should + be marked do_not_run. + + :param node: SimpleDAG internal node + :param parent_nodes: list of workflow_nodes + + Return a boolean + ''' + def _should_mark_node_dnr(self, node, parent_nodes): + for p in parent_nodes: + if p.job: + if p.job.status == 'successful': + if node in (self.get_dependencies(p, 'success_nodes') + + self.get_dependencies(p, 'always_nodes')): + return False + elif p.job.status == 'failed': + if node in (self.get_dependencies(p, 'failure_nodes') + + self.get_dependencies(p, 'always_nodes')): + return False + else: + return False + else: + return False + return True + def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() nodes = copy.copy(root_nodes) @@ -133,46 +177,23 @@ class WorkflowDAG(SimpleDAG): if obj.id in node_ids_visited: continue node_ids_visited.add(obj.id) - job = obj.job - if obj.do_not_run is False and not job and n not in root_nodes: - parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] - all_parents_dnr = True - parent_run_path = False - for p in parent_nodes: - if p.do_not_run is True: - continue + if obj.do_not_run is False and not obj.job and n not in root_nodes: + parent_nodes = filter(lambda n: not n.do_not_run, + [p['node_object'] for p in self.get_dependents(obj)]) + if self._are_all_nodes_dnr_decided(parent_nodes): + if self._should_mark_node_dnr(n, parent_nodes): + obj.do_not_run = True + nodes_marked_do_not_run.append(n) - if not p.job and p.do_not_run is False: - all_parents_dnr = False - - elif p.job and p.job.status in ['new', 'pending', 'waiting', 'running']: - parent_run_path = True - - elif p.job and p.job.status == 'successful': - children_success = self.get_dependencies(p, 'success_nodes') - children_always = self.get_dependencies(p, 'always_nodes') - if n in children_success or n in children_always: - parent_run_path = True - - elif p.job and p.job.status == 'failed': - children_failed = self.get_dependencies(p, 'failure_nodes') - children_always = self.get_dependencies(p, 'always_nodes') - if n in children_always or n in children_failed: - parent_run_path = True - - #all_parents_dnr = reduce(lambda p: bool(p.do_not_run == True), parent_nodes) - if all_parents_dnr and parent_run_path is False: - obj.do_not_run = True - nodes_marked_do_not_run.append(n) - elif obj.do_not_run is True: + if obj.do_not_run is True: nodes.extend(self.get_dependencies(obj, 'success_nodes') + self.get_dependencies(obj, 'failure_nodes') + self.get_dependencies(obj, 'always_nodes')) - elif job: - if job.status == 'failed': + elif obj.job: + if obj.job.status == 'failed': nodes.extend(self.get_dependencies(obj, 'success_nodes')) - elif job.status == 'successful': + elif obj.job.status == 'successful': nodes.extend(self.get_dependencies(obj, 'failure_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From ea29e66a41aee61d15f7dc526b8cb132e907e987 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 18 Oct 2018 09:09:47 -0400 Subject: [PATCH 16/99] fix workflow finish state detector * Take into account the new do_not_run field when finding if a workflow is finished. If do_not_run is True then the node is considered finished. --- awx/main/scheduler/dag_workflow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index e5c3243a58..a077c03eec 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -95,8 +95,10 @@ class WorkflowDAG(SimpleDAG): if obj.unified_job_template is None: is_failed = True continue - elif not job: + elif obj.do_not_run is False and not job: return False, False + elif obj.do_not_run is True: + continue children_success = self.get_dependencies(obj, 'success_nodes') children_failed = self.get_dependencies(obj, 'failure_nodes') From 2742b00a65eb416fe9f4bbb258b511bbf71e779a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 18 Oct 2018 09:11:34 -0400 Subject: [PATCH 17/99] flake8 --- awx/main/scheduler/dag_workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index a077c03eec..0dbcab23e2 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -156,11 +156,11 @@ class WorkflowDAG(SimpleDAG): if p.job: if p.job.status == 'successful': if node in (self.get_dependencies(p, 'success_nodes') + - self.get_dependencies(p, 'always_nodes')): + self.get_dependencies(p, 'always_nodes')): return False elif p.job.status == 'failed': if node in (self.get_dependencies(p, 'failure_nodes') + - self.get_dependencies(p, 'always_nodes')): + self.get_dependencies(p, 'always_nodes')): return False else: return False From 475c90fd006ee77b8e6d297045daf07ec3b2b0a3 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 19 Oct 2018 14:58:32 -0400 Subject: [PATCH 18/99] prevent job launching twice --- awx/main/scheduler/dag_workflow.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 0dbcab23e2..f973fcf4d8 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -51,9 +51,13 @@ class WorkflowDAG(SimpleDAG): def bfs_nodes_to_run(self): nodes = self.get_root_nodes() nodes_found = [] + node_ids_visited = set() for index, n in enumerate(nodes): obj = n['node_object'] + if obj.id in node_ids_visited: + continue + node_ids_visited.add(obj.id) if obj.do_not_run is True: continue From 02df0c29e96bceb96b8de4854eb675067905ec42 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 22 Oct 2018 10:08:40 -0400 Subject: [PATCH 19/99] merge artifacts deterministically --- awx/main/models/workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 946812350b..200fd3b645 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -82,7 +82,7 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig): success_parents = getattr(self, '%ss_success' % self.__class__.__name__.lower()).all() failure_parents = getattr(self, '%ss_failure' % self.__class__.__name__.lower()).all() always_parents = getattr(self, '%ss_always' % self.__class__.__name__.lower()).all() - return success_parents | failure_parents | always_parents + return (success_parents | failure_parents | always_parents).order_by('id') @classmethod def _get_workflow_job_field_names(cls): From 4111e53113c8b2473adcf07bdd3a6e9a62974d92 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 22 Oct 2018 10:47:59 -0400 Subject: [PATCH 20/99] correctly name migration to align with 3.4.0 --- ..._workflow_convergence.py => 0050_v340_workflow_convergence.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename awx/main/migrations/{0050_v331_workflow_convergence.py => 0050_v340_workflow_convergence.py} (100%) diff --git a/awx/main/migrations/0050_v331_workflow_convergence.py b/awx/main/migrations/0050_v340_workflow_convergence.py similarity index 100% rename from awx/main/migrations/0050_v331_workflow_convergence.py rename to awx/main/migrations/0050_v340_workflow_convergence.py From 1e10d4323fc85c1071f27004b08aa04ff8eee3e7 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 22 Oct 2018 10:51:29 -0400 Subject: [PATCH 21/99] update docs --- docs/workflow.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/workflow.md b/docs/workflow.md index f27ebce5eb..1235353169 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -57,7 +57,6 @@ This is to prevent saturation of the task system with an infinite chain of workf ### Tree-Graph Formation and Restrictions The tree-graph structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. However there are restrictions that must be enforced when setting up new connections. Here are the three restrictions that will raise validation error when break: * Cycle restriction: According to tree definition, no cycle is allowed. -* Convergent restriction: Different paths should not come into the same node, in other words, a node cannot have multiple parents. > Note: A node can now have all three types of child nodes. @@ -113,7 +112,7 @@ Artifact support starts in Ansible and is carried through in Tower. The `set_sta * Verify that workflow job template nodes can be created under, or (dis)associated with workflow job templates. * Verify that the permitted types of job template types can be associated with a workflow job template node. Currently the permitted types are *job templates, inventory sources, projects, and workflow job templates*. * Verify that workflow job template nodes under the same workflow job template can be associated to form parent-child relationship of decision trees. In specific, one node takes another as its child node by POSTing another node's id to one of the three endpoints: `/success_nodes/`, `/failure_nodes/` and `/always_nodes/`. -* Verify that workflow job template nodes are not allowed to have invalid association. Any attempt that causes invalidity will trigger 400-level response. The three types of invalid associations are cycle, convergence(multiple parent). +* Verify that workflow job template nodes are not allowed to have invalid association. Any attempt that causes invalidity will trigger 400-level response (i.e. cycles). * Verify that a workflow job template can be successfully copied and the created workflow job template does not miss any field that should be copied or intentionally modified. * Verify that if a user has no access to any of the related resources of a workflow job template node, that node will not be copied and will have `null` as placeholder. * Verify that `artifacts` is populated when `set_stats` is used in Ansible >= v2.2.1.0-0.3.rc3. From 87d6253176f250d78efc479b43bec89e14325c07 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 4 Oct 2018 20:29:10 -0600 Subject: [PATCH 22/99] Decouple editing a wf node with editing a node link --- .../features/templates/templates.strings.js | 1 + .../workflow-chart/workflow-chart.block.less | 39 +- .../workflow-chart.directive.js | 2261 +++++++++-------- .../workflows/workflow-maker/forms/main.js | 7 + .../forms/workflow-link-form.controller.js | 38 + .../forms/workflow-link-form.directive.js | 22 + .../forms/workflow-link-form.partial.html | 25 + .../forms/workflow-node-form.controller.js | 11 + .../forms/workflow-node-form.directive.js | 21 + .../forms/workflow-node-form.partial.html | 51 + .../workflows/workflow-maker/main.js | 3 +- .../workflow-maker/workflow-maker.block.less | 1 - .../workflow-maker.controller.js | 99 +- .../workflow-maker.partial.html | 119 +- 14 files changed, 1504 insertions(+), 1194 deletions(-) create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js create mode 100644 awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 0efcff23c4..785d9902cc 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -122,6 +122,7 @@ function TemplatesStrings (BaseString) { INVENTORY_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden by the parent workflow inventory.'), INVENTORY_PROMPT_WILL_OVERRIDE: t.s('The inventory of this node will be overridden if a parent workflow inventory is provided at launch.'), INVENTORY_PROMPT_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden if a parent workflow inventory is provided at launch.'), + EDIT_LINK: ({ parentName, childName }) => t.s('EDIT LINK | {{parentName}} to {{childName}}', { parentName, childName }) } } diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 52242d6387..f5e9bd7378 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -1,4 +1,9 @@ -.link circle, .link .linkCross, .node .addCircle, .node .removeCircle, .node .WorkflowChart-hoverPath { +.link circle, +.link polygon, +.link .linkCross, +.node circle, +.node .linkIcon, +.node .WorkflowChart-hoverPath { opacity: 0; } @@ -18,6 +23,18 @@ fill: @default-err-hov; } +.node .linkCircle { + fill: @default-link; +} + +.node .linkIcon { + color: @default-bg; +} + +.linkCircle.removeHovering { + fill: @default-link-hov; +} + .node { font-size: 12px; font-family: 'Open Sans', sans-serif, 'FontAwesome'; @@ -50,8 +67,12 @@ .WorkflowChart-alwaysShowAdd .linkCross, .hovering .addCircle, .hovering .removeCircle, +.addHovering .betweenNodesCircle, +.hovering .linkCircle, +.hovering .linkIcon, .hovering .WorkflowChart-hoverPath, -.hovering .linkCross { +.addHovering .linkCross { + cursor: pointer; opacity: 1; } @@ -136,3 +157,17 @@ .WorkflowChart-dashedNode { stroke-dasharray: 5,5; } + +.linkOverlay { + fill: @default-interface-txt; +} + +.linkActiveEdit.linkOverlay, +.overlayHovering .linkOverlay { + cursor: pointer; + opacity: 0.4; +} + +.overlayHovering .linkPath { + cursor: pointer; +} diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index fefeecf385..bbf6610e3b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -4,1187 +4,1188 @@ * All Rights Reserved *************************************************/ -export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'GetBasePath', 'ProcessErrors', 'TemplatesStrings', - function ($state, moment, $timeout, $window, $filter, Rest, GetBasePath, ProcessErrors, TemplatesStrings) { +export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'GetBasePath', 'ProcessErrors', 'TemplatesStrings', + function($state, moment, $timeout, $window, $filter, Rest, GetBasePath, ProcessErrors, TemplatesStrings) { - return { - scope: { - treeData: '=', - canAddWorkflowJobTemplate: '=', - workflowJobTemplateObj: '=', - addNode: '&', - editNode: '&', - deleteNode: '&', - workflowZoomed: '&', - mode: '@' - }, - restrict: 'E', - link: function (scope, element) { + return { + scope: { + treeData: '=', + canAddWorkflowJobTemplate: '=', + workflowJobTemplateObj: '=', + addNode: '&', + editNode: '&', + deleteNode: '&', + editLink: '&', + workflowZoomed: '&', + mode: '@' + }, + restrict: 'E', + link: function(scope, element) { - let marginLeft = 20, - i = 0, - nodeW = 180, - nodeH = 60, - rootW = 60, - rootH = 40, - startNodeOffsetY = scope.mode === 'details' ? 17 : 10, - verticalSpaceBetweenNodes = 20, - maxNodeTextLength = 27, - windowHeight, - windowWidth, - tree, - line, - zoomObj, - baseSvg, - svgGroup, - graphLoaded; + let marginLeft = 20, + i = 0, + nodeW = 180, + nodeH = 60, + rootW = 60, + rootH = 40, + startNodeOffsetY = scope.mode === 'details' ? 17 : 10, + verticalSpaceBetweenNodes = 20, + maxNodeTextLength = 27, + windowHeight, + windowWidth, + tree, + line, + zoomObj, + baseSvg, + svgGroup, + graphLoaded; - scope.dimensionsSet = false; + scope.dimensionsSet = false; - $timeout(function () { - let dimensions = calcAvailableScreenSpace(); + $timeout(function(){ + let dimensions = calcAvailableScreenSpace(); - windowHeight = dimensions.height; - windowWidth = dimensions.width; + windowHeight = dimensions.height; + windowWidth = dimensions.width; - $('.WorkflowMaker-chart').css("height", windowHeight); + $('.WorkflowMaker-chart').css("height", windowHeight); - scope.dimensionsSet = true; + scope.dimensionsSet = true; - init(); - }); + init(); + }); - function init() { - tree = d3.layout.tree() - .nodeSize([nodeH + verticalSpaceBetweenNodes, nodeW]) - .separation(function (a, b) { + function init() { + tree = d3.layout.tree() + .nodeSize([nodeH + verticalSpaceBetweenNodes,nodeW]) + .separation(function(a, b) { // This should tighten up some of the other nodes so there's not so much wasted space return a.parent === b.parent ? 1 : 1.25; }); - line = d3.svg.line() - .x(function (d) { - return d.x; - }) - .y(function (d) { - return d.y; + line = d3.svg.line() + .x(function(d){return d.x;}) + .y(function(d){return d.y;}); + + zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); + + baseSvg = d3.select(element[0]).append("svg") + .attr("class", "WorkflowChart-svg") + .call(zoomObj + .on("zoom", naturalZoom) + ); + + svgGroup = baseSvg.append("g") + .attr("id", "aw-workflow-chart-g") + .attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + } + + function calcAvailableScreenSpace() { + let dimensions = {}; + + if(scope.mode !== 'details') { + // This is the workflow editor + dimensions.height = $('.WorkflowMaker-contentLeft').outerHeight() - $('.WorkflowLegend-maker').outerHeight(); + dimensions.width = $('#workflow-modal-dialog').width() - $('.WorkflowMaker-contentRight').outerWidth(); + } + else { + // This is the workflow details view + let panel = $('.WorkflowResults-rightSide').children('.Panel')[0]; + let panelWidth = $(panel).width(); + let panelHeight = $(panel).height(); + let headerHeight = $('.StandardOut-panelHeader').outerHeight(); + let legendHeight = $('.WorkflowLegend-details').outerHeight(); + let proposedHeight = panelHeight - headerHeight - legendHeight - 40; + + dimensions.height = proposedHeight > 200 ? proposedHeight : 200; + dimensions.width = panelWidth; + } + + return dimensions; + } + + function lineData(d){ + + let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; + let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; + let targetX = d.target.y; + let targetY = d.target.x + nodeH / 2; + + let points = [ + { + x: sourceX, + y: sourceY + }, + { + x: targetX, + y: targetY + } + ]; + + return line(points); + } + + // TODO: this function is hacky and we need to come up with a better solution + // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 + function wrap(text) { + if(text && text.length > maxNodeTextLength) { + return text.substring(0,maxNodeTextLength) + '...'; + } + else { + return text; + } + } + + function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { + var retval; + retval = "M" + (x + r) + "," + y; + retval += "h" + (w - 2*r); + if (tr) { retval += "a" + r + "," + r + " 0 0 1 " + r + "," + r; } + else { retval += "h" + r; retval += "v" + r; } + retval += "v" + (h - 2*r); + if (br) { retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + r; } + else { retval += "v" + r; retval += "h" + -r; } + retval += "h" + (2*r - w); + if (bl) { retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + -r; } + else { retval += "h" + -r; retval += "v" + -r; } + retval += "v" + (2*r - h); + if (tl) { retval += "a" + r + "," + r + " 0 0 1 " + r + "," + -r; } + else { retval += "v" + -r; retval += "h" + r; } + retval += "z"; + return retval; + } + + // This is the zoom function called by using the mousewheel/click and drag + function naturalZoom() { + let scale = d3.event.scale, + translation = d3.event.translate; + + translation = [translation[0] + (marginLeft*scale), translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; + + svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); + + scope.workflowZoomed({ + zoom: scale + }); + } + + // This is the zoom that gets called when the user interacts with the manual zoom controls + function manualZoom(zoom) { + let scale = zoom / 100, + translation = zoomObj.translate(), + origZoom = zoomObj.scale(), + unscaledOffsetX = (translation[0] + ((windowWidth*origZoom) - windowWidth)/2)/origZoom, + unscaledOffsetY = (translation[1] + ((windowHeight*origZoom) - windowHeight)/2)/origZoom, + translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, + translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; + + svgGroup.attr("transform", "translate(" + [translateX + (marginLeft*scale), translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); + zoomObj.scale(scale); + zoomObj.translate([translateX, translateY]); + } + + function manualPan(direction) { + let scale = zoomObj.scale(), + distance = 150 * scale, + translateX, + translateY, + translateCoords = zoomObj.translate(); + if (direction === 'left' || direction === 'right') { + translateX = direction === 'left' ? translateCoords[0] - distance : translateCoords[0] + distance; + translateY = translateCoords[1]; + } else if (direction === 'up' || direction === 'down') { + translateX = translateCoords[0]; + translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; + } + svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)) + ")scale(" + scale + ")"); + zoomObj.translate([translateX, translateY]); + } + + function resetZoomAndPan() { + svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + // Update the zoomObj + zoomObj.scale(1); + zoomObj.translate([0,0]); + } + + function zoomToFitChart() { + let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), + startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), + availableScreenSpace = calcAvailableScreenSpace(), + currentZoomValue = zoomObj.scale(), + unscaledH = graphDimensions.height/currentZoomValue, + unscaledW = graphDimensions.width/currentZoomValue, + scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, + scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft)/unscaledW, + lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10), + startNodeOffsetFromGraphCenter = Math.round((((rootH/2) + (startNodeDimensions.top/currentZoomValue)) - ((graphDimensions.top/currentZoomValue) + (unscaledH/2)))*scaleToFit); + + manualZoom(scaleToFit*100); + + scope.workflowZoomed({ + zoom: scaleToFit + }); + + svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); + zoomObj.translate([marginLeft - scaleToFit*marginLeft, windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); + + } + + function update() { + let userCanAddEdit = (scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; + if(scope.dimensionsSet) { + // Declare the nodes + let nodes = tree.nodes(scope.treeData), + links = tree.links(nodes); + + let node = svgGroup.selectAll("g.node") + .data(nodes, function(d) { + d.y = d.depth * 240; + return d.id || (d.id = ++i); }); - zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); + let nodeEnter = node.enter().append("g") + .attr("class", "node") + .attr("id", function(d){return "node-" + d.id;}) + .attr("parent", function(d){return d.parent ? d.parent.id : null;}) + .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); - baseSvg = d3.select(element[0]).append("svg") - .attr("class", "WorkflowChart-svg") - .call(zoomObj - .on("zoom", naturalZoom) - ); - - svgGroup = baseSvg.append("g") - .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - rootH / 2 - startNodeOffsetY) + ")"); - } - - function calcAvailableScreenSpace() { - let dimensions = {}; - - if (scope.mode !== 'details') { - // This is the workflow editor - dimensions.height = $('.WorkflowMaker-contentLeft').outerHeight() - $('.WorkflowLegend-maker').outerHeight(); - dimensions.width = $('#workflow-modal-dialog').width() - $('.WorkflowMaker-contentRight').outerWidth(); - } else { - // This is the workflow details view - let panel = $('.WorkflowResults-rightSide').children('.Panel')[0]; - let panelWidth = $(panel).width(); - let panelHeight = $(panel).height(); - let headerHeight = $('.StandardOut-panelHeader').outerHeight(); - let legendHeight = $('.WorkflowLegend-details').outerHeight(); - let proposedHeight = panelHeight - headerHeight - legendHeight - 40; - - dimensions.height = proposedHeight > 200 ? proposedHeight : 200; - dimensions.width = panelWidth; - } - - return dimensions; - } - - function lineData(d) { - - let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; - let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; - let targetX = d.target.y; - let targetY = d.target.x + nodeH / 2; - - let points = [{ - x: sourceX, - y: sourceY - }, - { - x: targetX, - y: targetY + nodeEnter.each(function(d) { + let thisNode = d3.select(this); + if(d.isStartNode && scope.mode === 'details') { + // Overwrite the default root height and width and replace it with a small blue square + rootW = 25; + rootH = 25; + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + .attr("y", startNodeOffsetY) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#337ab7") + .attr("class", "WorkflowChart-rootNode"); } - ]; - - return line(points); - } - - // TODO: this function is hacky and we need to come up with a better solution - // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 - function wrap(text) { - if (text && text.length > maxNodeTextLength) { - return text.substring(0, maxNodeTextLength) + '...'; - } else { - return text; - } - } - - function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { - var retval; - retval = "M" + (x + r) + "," + y; - retval += "h" + (w - 2 * r); - if (tr) { - retval += "a" + r + "," + r + " 0 0 1 " + r + "," + r; - } else { - retval += "h" + r; - retval += "v" + r; - } - retval += "v" + (h - 2 * r); - if (br) { - retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + r; - } else { - retval += "v" + r; - retval += "h" + -r; - } - retval += "h" + (2 * r - w); - if (bl) { - retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + -r; - } else { - retval += "h" + -r; - retval += "v" + -r; - } - retval += "v" + (2 * r - h); - if (tl) { - retval += "a" + r + "," + r + " 0 0 1 " + r + "," + -r; - } else { - retval += "v" + -r; - retval += "h" + r; - } - retval += "z"; - return retval; - } - - // This is the zoom function called by using the mousewheel/click and drag - function naturalZoom() { - let scale = d3.event.scale, - translation = d3.event.translate; - - translation = [translation[0] + (marginLeft * scale), translation[1] + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)]; - - svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); - - scope.workflowZoomed({ - zoom: scale - }); - } - - // This is the zoom that gets called when the user interacts with the manual zoom controls - function manualZoom(zoom) { - let scale = zoom / 100, - translation = zoomObj.translate(), - origZoom = zoomObj.scale(), - unscaledOffsetX = (translation[0] + ((windowWidth * origZoom) - windowWidth) / 2) / origZoom, - unscaledOffsetY = (translation[1] + ((windowHeight * origZoom) - windowHeight) / 2) / origZoom, - translateX = unscaledOffsetX * scale - ((scale * windowWidth) - windowWidth) / 2, - translateY = unscaledOffsetY * scale - ((scale * windowHeight) - windowHeight) / 2; - - svgGroup.attr("transform", "translate(" + [translateX + (marginLeft * scale), translateY + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)] + ")scale(" + scale + ")"); - zoomObj.scale(scale); - zoomObj.translate([translateX, translateY]); - } - - function manualPan(direction) { - let scale = zoomObj.scale(), - distance = 150 * scale, - translateX, - translateY, - translateCoords = zoomObj.translate(); - if (direction === 'left' || direction === 'right') { - translateX = direction === 'left' ? translateCoords[0] - distance : translateCoords[0] + distance; - translateY = translateCoords[1]; - } else if (direction === 'up' || direction === 'down') { - translateX = translateCoords[0]; - translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; - } - svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)) + ")scale(" + scale + ")"); - zoomObj.translate([translateX, translateY]); - } - - function resetZoomAndPan() { - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - rootH / 2 - startNodeOffsetY) + ")scale(" + 1 + ")"); - // Update the zoomObj - zoomObj.scale(1); - zoomObj.translate([0, 0]); - } - - function zoomToFitChart() { - let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), - startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), - availableScreenSpace = calcAvailableScreenSpace(), - currentZoomValue = zoomObj.scale(), - unscaledH = graphDimensions.height / currentZoomValue, - unscaledW = graphDimensions.width / currentZoomValue, - scaleNeededForMaxHeight = (availableScreenSpace.height) / unscaledH, - scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft) / unscaledW, - lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), - scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10) / 10), - startNodeOffsetFromGraphCenter = Math.round((((rootH / 2) + (startNodeDimensions.top / currentZoomValue)) - ((graphDimensions.top / currentZoomValue) + (unscaledH / 2))) * scaleToFit); - - manualZoom(scaleToFit * 100); - - scope.workflowZoomed({ - zoom: scaleToFit - }); - - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - (nodeH * scaleToFit / 2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); - zoomObj.translate([marginLeft - scaleToFit * marginLeft, windowHeight / 2 - (nodeH * scaleToFit / 2) + startNodeOffsetFromGraphCenter - ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scaleToFit)]); - - } - - function update() { - let userCanAddEdit = (scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; - if (scope.dimensionsSet) { - // Declare the nodes - let nodes = tree.nodes(scope.treeData), - links = tree.links(nodes); - let node = svgGroup.selectAll("g.node") - .data(nodes, function (d) { - d.y = d.depth * 240; - return d.id || (d.id = ++i); - }); - - let nodeEnter = node.enter().append("g") - .attr("class", "node") - .attr("id", function (d) { - return "node-" + d.id; - }) - .attr("parent", function (d) { - return d.parent ? d.parent.id : null; - }) - .attr("transform", function (d) { - return "translate(" + d.y + "," + d.x + ")"; - }); - - nodeEnter.each(function (d) { - let thisNode = d3.select(this); - if (d.isStartNode && scope.mode === 'details') { - // Overwrite the default root height and width and replace it with a small blue square - rootW = 25; - rootH = 25; - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", startNodeOffsetY) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#337ab7") - .attr("class", "WorkflowChart-rootNode"); - } else if (d.isStartNode && scope.mode !== 'details') { - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", 10) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#5cb85c") - .attr("class", "WorkflowChart-rootNode") - .call(add_node); - thisNode.append("text") - .attr("x", 13) - .attr("y", 30) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-startText") - .text(function () { - return TemplatesStrings.get('workflow_maker.START'); - }) - .call(add_node); - } else { - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("rx", 5) - .attr("ry", 5) - .attr('stroke', function (d) { - if (d.job && d.job.status) { - if (d.job.status === "successful") { - return "#5cb85c"; - } else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } else { - return "#D7D7D7"; - } - } else { + else if(d.isStartNode && scope.mode !== 'details') { + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + .attr("y", 10) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#5cb85c") + .attr("class", "WorkflowChart-rootNode") + .call(add_node); + thisNode.append("text") + .attr("x", 13) + .attr("y", 30) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-startText") + .text(function () { return TemplatesStrings.get('workflow_maker.START'); }) + .call(add_node); + } + else { + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("rx", 5) + .attr("ry", 5) + .attr('stroke', function(d) { + if(d.job && d.job.status) { + if(d.job.status === "successful"){ + return "#5cb85c"; + } + else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } + else { return "#D7D7D7"; } - }) - .attr('stroke-width', "2px") - .attr("class", function (d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); - - thisNode.append("path") - .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) - .attr("class", "WorkflowChart-activeNode") - .style("display", function (d) { - return d.isActiveEdit ? null : "none"; - }); - - thisNode.append("text") - .attr("x", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; - }) - .attr("y", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; - }) - .attr("dy", ".35em") - .attr("text-anchor", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; - }) - .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - }).each(wrap); - - thisNode.append("foreignObject") - .attr("x", 54) - .attr("y", 45) - .style("font-size", "0.7em") - .attr("class", "WorkflowChart-conflictText") - .html(function () { - return `\uf06a ${TemplatesStrings.get('workflow_maker.EDGE_CONFLICT')}`; - }) - .style("display", function (d) { - return (d.edgeConflict && !d.placeholder) ? null : "none"; - }); - - thisNode.append("foreignObject") - .attr("x", 62) - .attr("y", 22) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-defaultText WorkflowChart-deletedText") - .html(function () { - return `${TemplatesStrings.get('workflow_maker.DELETED')}`; - }) - .style("display", function (d) { - return d.unifiedJobTemplate || d.placeholder ? "none" : null; - }); - - thisNode.append("circle") - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "WorkflowChart-nodeTypeCircle") - .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); - - thisNode.append("text") - .attr("y", nodeH) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-nodeTypeLetter") - .text(function (d) { - let nodeTypeLetter = ""; - if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { - switch (d.unifiedJobTemplate.type) { - case "project": - nodeTypeLetter = "P"; - break; - case "inventory_source": - nodeTypeLetter = "I"; - break; - case "workflow_job_template": - nodeTypeLetter = "W"; - break; - } - } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { - switch (d.unifiedJobTemplate.unified_job_type) { - case "project_update": - nodeTypeLetter = "P"; - break; - case "inventory_update": - nodeTypeLetter = "I"; - break; - case "workflow_job": - nodeTypeLetter = "W"; - break; - } - } - return nodeTypeLetter; - }) - .style("display", function (d) { - return d.unifiedJobTemplate && - (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); - - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("class", "transparentRect") - .call(edit_node) - .on("mouseover", function (d) { - if (!d.isStartNode) { - let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - if (resourceName && resourceName.length > maxNodeTextLength) { - // When the graph is initially rendered all the links come after the nodes (when you look at the dom). - // SVG components are painted in order of appearance. There is no concept of z-index, only the order. - // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top - // of the links and not underneath them. I tried rendering the links before the nodes but that lead to - // some weird link animation that I didn't care to try to fix. - svgGroup.selectAll("g.node").each(function () { - this.parentNode.appendChild(this); - }); - // After the nodes have been properly placed after the links, we need to make sure that the node that - // the user is hovering over is at the very end of the list. This way the tooltip will appear on top - // of all other nodes. - svgGroup.selectAll("g.node").sort(function (a) { - return (a.id !== d.id) ? -1 : 1; - }); - // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place - // it properly on the workflow - let tooltipDimensionChecker = $(""); - $('body').append(tooltipDimensionChecker); - let tipWidth = $(tooltipDimensionChecker).outerWidth(); - let tipHeight = $(tooltipDimensionChecker).outerHeight(); - $(tooltipDimensionChecker).remove(); - - thisNode.append("foreignObject") - .attr("x", (nodeW / 2) - (tipWidth / 2)) - .attr("y", (tipHeight + 15) * -1) - .attr("width", tipWidth) - .attr("height", tipHeight + 20) - .attr("class", "WorkflowChart-tooltip") - .html(function () { - return "
" + $filter('sanitize')(resourceName) + "
"; - }); - } - d3.select("#node-" + d.id) - .classed("hovering", true); - } - }) - .on("mouseout", function (d) { - $('.WorkflowChart-tooltip').remove(); - if (!d.isStartNode) { - d3.select("#node-" + d.id) - .classed("hovering", false); - } - }); - thisNode.append("text") - .attr("x", nodeW - 45) - .attr("y", nodeH - 10) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-detailsLink") - .style("display", function (d) { - return d.job && d.job.status && d.job.id ? null : "none"; - }) - .text(function () { - return TemplatesStrings.get('workflow_maker.DETAILS'); - }) - .call(details); - thisNode.append("circle") - .attr("id", function (d) { - return "node-" + d.id + "-add"; - }) - .attr("cx", nodeW) - .attr("r", 10) - .attr("class", "addCircle nodeCircle") - .style("display", function (d) { - return d.placeholder || !(userCanAddEdit) ? "none" : null; - }) - .call(add_node) - .on("mouseover", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeAddCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function () { - return "translate(" + nodeW + "," + 0 + ")"; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function (d) { - return d.placeholder || !(userCanAddEdit) ? "none" : null; - }) - .call(add_node) - .on("mouseover", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("circle") - .attr("id", function (d) { - return "node-" + d.id + "-remove"; - }) - .attr("cx", nodeW) - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "removeCircle") - .style("display", function (d) { - return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .call(remove_node) - .on("mouseover", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function () { - return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function (d) { - return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .call(remove_node) - .on("mouseover", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); - - thisNode.append("circle") - .attr("class", function (d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if (d.job) { - switch (d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; - break; - } - } - - return statusClass; - }) - .style("display", function (d) { - return d.job && d.job.status ? null : "none"; - }) - .attr("cy", 10) - .attr("cx", 10) - .attr("r", 6); - - thisNode.append("foreignObject") - .attr("x", 5) - .attr("y", 43) - .style("font-size", "0.7em") - .attr("class", "WorkflowChart-elapsed") - .html(function (d) { - if (d.job && d.job.elapsed) { - let elapsedMs = d.job.elapsed * 1000; - let elapsedMoment = moment.duration(elapsedMs); - let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); - let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); - return "
" + elapsedString + "
"; - } else { - return ""; - } - }) - .style("display", function (d) { - return (d.job && d.job.elapsed) ? null : "none"; - }); - } - }); - - node.exit().remove(); - - if (nodes && nodes.length > 1 && !graphLoaded) { - zoomToFitChart(); - } - - graphLoaded = true; - - let link = svgGroup.selectAll("g.link") - .data(links, function (d) { - return d.source.id + "-" + d.target.id; - }); - - let linkEnter = link.enter().append("g") - .attr("class", "link") - .attr("id", function (d) { - return "link-" + d.source.id + "-" + d.target.id; - }); - - // Add entering links in the parent’s old position. - linkEnter.insert("path", "g") - .attr("class", function (d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; - }) - .attr("d", lineData) - .attr('stroke', function (d) { - if (d.target.edgeType) { - if (d.target.edgeType === "failure") { - return "#d9534f"; - } else if (d.target.edgeType === "success") { - return "#5cb85c"; - } else if (d.target.edgeType === "always") { - return "#337ab7"; } - } else { - return "#D7D7D7"; - } - }); - - linkEnter.append("circle") - .attr("id", function (d) { - return "link-" + d.source.id + "-" + d.target.id + "-add"; - }) - .attr("cx", function (d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function (d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }) - .attr("r", 10) - .attr("class", "addCircle linkCircle") - .style("display", function (d) { - return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .call(add_node_between) - .on("mouseover", function (d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); - - linkEnter.append("path") - .attr("class", "linkCross") - .style("fill", "white") - .attr("transform", function (d) { - let translate; - if (d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 + ")"; - } else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function (d) { - return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .call(add_node_between) - .on("mouseover", function (d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function (d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); - - link.exit().remove(); - - // Transition nodes and links to their new positions. - let t = baseSvg.transition(); - - t.selectAll(".nodeCircle") - .style("display", function (d) { - return d.placeholder || !(userCanAddEdit) ? "none" : null; - }); - - t.selectAll(".nodeAddCross") - .style("display", function (d) { - return d.placeholder || !(userCanAddEdit) ? "none" : null; - }); - - t.selectAll(".removeCircle") - .style("display", function (d) { - return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; - }); - - t.selectAll(".nodeRemoveCross") - .style("display", function (d) { - return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; - }); - - t.selectAll(".linkPath") - .attr("class", function (d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; - }) - .attr("d", lineData) - .attr('stroke', function (d) { - if (d.target.edgeType) { - if (d.target.edgeType === "failure") { - return "#d9534f"; - } else if (d.target.edgeType === "success") { - return "#5cb85c"; - } else if (d.target.edgeType === "always") { - return "#337ab7"; - } - } else { - return "#D7D7D7"; - } - }); - - t.selectAll(".linkCircle") - .style("display", function (d) { - return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .attr("cx", function (d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function (d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); - - t.selectAll(".linkCross") - .style("display", function (d) { - return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; - }) - .attr("transform", function (d) { - let translate; - if (d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 + ")"; - } else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); - - t.selectAll(".rect") - .attr('stroke', function (d) { - if (d.job && d.job.status) { - if (d.job.status === "successful") { - return "#5cb85c"; - } else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } else { + else { return "#D7D7D7"; } - } else { - return "#D7D7D7"; - } - }) - .attr("class", function (d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); + }) + .attr('stroke-width', "2px") + .attr("class", function(d) { + let classString = d.placeholder ? "rect placeholder" : "rect"; + classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + return classString; + }); - t.selectAll(".node") - .attr("parent", function (d) { - return d.parent ? d.parent.id : null; - }) - .attr("transform", function (d) { - d.px = d.x; - d.py = d.y; - return "translate(" + d.y + "," + d.x + ")"; - }); + thisNode.append("path") + .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) + .attr("class", "WorkflowChart-activeNode") + .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - t.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || + thisNode.append("text") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("dy", ".35em") + .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) + .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; + }).each(wrap); + + thisNode.append("foreignObject") + .attr("x", 62) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-deletedText") + .html(function () { + return `${TemplatesStrings.get('workflow_maker.DELETED')}`; + }) + .style("display", function(d) { return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); + + thisNode.append("circle") + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "WorkflowChart-nodeTypeCircle") + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" || d.unifiedJobTemplate.type === "workflow_job_template" || d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; }); + thisNode.append("text") + .attr("y", nodeH) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-nodeTypeLetter") + .text(function (d) { + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; + }) + .style("display", function (d) { + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); - t.selectAll(".WorkflowChart-nodeTypeLetter") - .text(function (d) { - let nodeTypeLetter = ""; - if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { - switch (d.unifiedJobTemplate.type) { - case "project": - nodeTypeLetter = "P"; - break; - case "inventory_source": - nodeTypeLetter = "I"; - break; - case "workflow_job_template": - nodeTypeLetter = "W"; - break; + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("class", "transparentRect") + .call(edit_node) + .on("mouseover", function(d) { + if(!d.isStartNode) { + let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; + if(resourceName && resourceName.length > maxNodeTextLength) { + // When the graph is initially rendered all the links come after the nodes (when you look at the dom). + // SVG components are painted in order of appearance. There is no concept of z-index, only the order. + // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top + // of the links and not underneath them. I tried rendering the links before the nodes but that lead to + // some weird link animation that I didn't care to try to fix. + svgGroup.selectAll("g.node").each(function() { + this.parentNode.appendChild(this); + }); + // After the nodes have been properly placed after the links, we need to make sure that the node that + // the user is hovering over is at the very end of the list. This way the tooltip will appear on top + // of all other nodes. + svgGroup.selectAll("g.node").sort(function (a) { + return (a.id !== d.id) ? -1 : 1; + }); + // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place + // it properly on the workflow + let tooltipDimensionChecker = $(""); + $('body').append(tooltipDimensionChecker); + let tipWidth = $(tooltipDimensionChecker).outerWidth(); + let tipHeight = $(tooltipDimensionChecker).outerHeight(); + $(tooltipDimensionChecker).remove(); + + thisNode.append("foreignObject") + .attr("x", (nodeW / 2) - (tipWidth / 2)) + .attr("y", (tipHeight + 15) * -1) + .attr("width", tipWidth) + .attr("height", tipHeight+20) + .attr("class", "WorkflowChart-tooltip") + .html(function(){ + return "
" + $filter('sanitize')(resourceName) + "
"; + }); + } + d3.select("#node-" + d.id) + .classed("hovering", true); } - } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { - switch (d.unifiedJobTemplate.unified_job_type) { - case "project_update": - nodeTypeLetter = "P"; - break; - case "inventory_update": - nodeTypeLetter = "I"; - break; - case "workflow_job": - nodeTypeLetter = "W"; - break; + }) + .on("mouseout", function(d){ + $('.WorkflowChart-tooltip').remove(); + if(!d.isStartNode) { + d3.select("#node-" + d.id) + .classed("hovering", false); + } + }); + thisNode.append("text") + .attr("x", nodeW - 45) + .attr("y", nodeH - 10) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-detailsLink") + .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) + .text(function () { + return TemplatesStrings.get('workflow_maker.DETAILS'); + }) + .call(details); + thisNode.append("circle") + .attr("id", function(d){return "node-" + d.id + "-add";}) + .attr("cx", nodeW) + .attr("r", 10) + .attr("class", "addCircle nodeCircle") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + .call(add_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeAddCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + .call(add_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("circle") + .attr("id", function(d){return "node-" + d.id + "-remove";}) + .attr("cx", nodeW) + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "removeCircle") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + // thisNode.append("circle") + // .attr("id", function(d){return "node-" + d.id + "-link";}) + // .attr("cx", nodeW) + // .attr("cy", nodeH/2) + // .attr("r", 10) + // .attr("class", "linkCircle nodeCircle") + // .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + // .call(link_node) + // .on("mouseover", function(d) { + // d3.select("#node-" + d.id) + // .classed("hovering", true); + // d3.select("#node-" + d.id + "-link") + // .classed("addHovering", true); + // }) + // .on("mouseout", function(d){ + // d3.select("#node-" + d.id) + // .classed("hovering", false); + // d3.select("#node-" + d.id + "-link") + // .classed("addHovering", false); + // }); + // // TODO: clean up the placement of this icon... this works but it's not + // // clean + // thisNode.append("foreignObject") + // .attr("x", nodeW - 6) + // .attr("y", nodeH/2 - 9) + // .style("font-size","14px") + // .html(function () { + // return ``; + // }) + // .attr("class", "linkIcon") + // .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) + // .call(link_node) + // .on("mouseover", function(d) { + // d3.select("#node-" + d.id) + // .classed("hovering", true); + // d3.select("#node-" + d.id + "-link") + // .classed("addHovering", true); + // }) + // .on("mouseout", function(d){ + // d3.select("#node-" + d.id) + // .classed("hovering", false); + // d3.select("#node-" + d.id + "-link") + // .classed("addHovering", false); + // }); + + thisNode.append("circle") + .attr("class", function(d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if(d.job){ + switch(d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "canceled": + statusClass += "workflowChart-nodeStatus--canceled"; + break; + } + } + + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .attr("cy", 10) + .attr("cx", 10) + .attr("r", 6); + + thisNode.append("foreignObject") + .attr("x", 5) + .attr("y", 43) + .style("font-size","0.7em") + .attr("class", "WorkflowChart-elapsed") + .html(function (d) { + if(d.job && d.job.elapsed) { + let elapsedMs = d.job.elapsed * 1000; + let elapsedMoment = moment.duration(elapsedMs); + let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); + let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); + return "
" + elapsedString + "
"; + } + else { + return ""; + } + }) + .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + } + }); + + node.exit().remove(); + + if(nodes && nodes.length > 1 && !graphLoaded) { + zoomToFitChart(); + } + + graphLoaded = true; + + let link = svgGroup.selectAll("g.link") + .data(links, function(d) { + return d.source.id + "-" + d.target.id; + }); + + let linkEnter = link.enter().append("g") + .attr("class", "link") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + + linkEnter.append("polygon", "g") + .attr("class", function(d) { + let linkClasses = ["linkOverlay"]; + if (d.source.isLinkEditParent && d.target.isLinkEditChild) { + linkClasses.push("linkActiveEdit"); + } + return linkClasses.join(' '); + }) + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) + .attr("points",function(d) { + const pt1 = [d.source.y + nodeW, d.source.x + 10 + nodeH/2].join(","); + const pt2 = [d.target.y,d.target.x + 10 + nodeH/2].join(","); + const pt3 = [d.target.y,d.target.x - 10 + nodeH/2].join(","); + const pt4 = [d.source.y + nodeW,d.source.x - 10 + nodeH/2].join(","); + return [pt1, pt2, pt3, pt4].join(" "); + }) + .call(edit_link) + .on("mouseover", function(d) { + if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("overlayHovering", true); + } + }) + .on("mouseout", function(d){ + if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("overlayHovering", false); + } + }); + + // Add entering links in the parent’s old position. + linkEnter.append("path", "g") + .attr("class", function(d) { + return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + }) + .attr("d", lineData) + .call(edit_link) + .on("mouseover", function(d) { + if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("overlayHovering", true); + } + }) + .on("mouseout", function(d){ + if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("overlayHovering", false); + } + }) + .attr('stroke', function(d) { + if(d.target.edgeType) { + if(d.target.edgeType === "failure") { + return "#d9534f"; + } + else if(d.target.edgeType === "success") { + return "#5cb85c"; + } + else if(d.target.edgeType === "always"){ + return "#337ab7"; + } + } + else { + return "#D7D7D7"; + } + }); + + linkEnter.append("circle") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) + .attr("cx", function(d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function(d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }) + .attr("r", 10) + .attr("class", "addCircle betweenNodesCircle") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(add_node_between) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("addHovering", false); + }); + + linkEnter.append("path") + .attr("class", "linkCross") + .style("fill", "white") + .attr("transform", function(d) { + let translate; + if(d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; + } + else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .call(add_node_between) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("addHovering", false); + }); + + link.exit().remove(); + + // Transition nodes and links to their new positions. + let t = baseSvg.transition(); + + t.selectAll(".nodeCircle") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); + + t.selectAll(".nodeAddCross") + .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); + + t.selectAll(".removeCircle") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); + + t.selectAll(".nodeRemoveCross") + .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); + + t.selectAll(".linkPath") + .attr("class", function(d) { + return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + }) + .attr("d", lineData) + .attr('stroke', function(d) { + if(d.target.edgeType) { + if(d.target.edgeType === "failure") { + return "#d9534f"; + } + else if(d.target.edgeType === "success") { + return "#5cb85c"; + } + else if(d.target.edgeType === "always"){ + return "#337ab7"; } } - return nodeTypeLetter; - }) - .style("display", function (d) { - return d.unifiedJobTemplate && - (d.unifiedJobTemplate.type === "project" || + else { + return "#D7D7D7"; + } + }); + + t.selectAll(".betweenNodesCircle") + .attr("cx", function(d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function(d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }); + + t.selectAll(".linkOverlay") + .attr("class", function(d) { + let linkClasses = ["linkOverlay"]; + if (d.source.isLinkEditParent && d.target.isLinkEditChild) { + linkClasses.push("linkActiveEdit"); + } + return linkClasses.join(' '); + }) + .attr("points",function(d) { + const pt1 = [d.source.y + nodeW, d.source.x + 10 + nodeH/2].join(","); + const pt2 = [d.target.y,d.target.x + 10 + nodeH/2].join(","); + const pt3 = [d.target.y,d.target.x - 10 + nodeH/2].join(","); + const pt4 = [d.source.y + nodeW,d.source.x - 10 + nodeH/2].join(","); + return [pt1, pt2, pt3, pt4].join(" "); + }); + + t.selectAll(".linkCross") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + if(d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; + } + else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }); + + t.selectAll(".rect") + .attr('stroke', function(d) { + if(d.job && d.job.status) { + if(d.job.status === "successful"){ + return "#5cb85c"; + } + else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } + else { + return "#D7D7D7"; + } + } + else { + return "#D7D7D7"; + } + }) + .attr("class", function(d) { + let classString = d.placeholder ? "rect placeholder" : "rect"; + classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + return classString; + }); + + t.selectAll(".node") + .attr("parent", function(d){return d.parent ? d.parent.id : null;}) + .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); + + t.selectAll(".WorkflowChart-nodeTypeCircle") + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" || d.unifiedJobTemplate.type === "workflow_job_template" || d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); + }); - t.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function (d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if (d.job) { - switch (d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; - break; - } + t.selectAll(".WorkflowChart-nodeTypeLetter") + .text(function (d) { + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; } - - return statusClass; - }) - .style("display", function (d) { - return d.job && d.job.status ? null : "none"; - }) - .transition() - .duration(0) - .attr("r", 6) - .each(function (d) { - if (d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { - // Pulse the circle - var circle = d3.select(this); - (function repeat() { - circle = circle.transition() - .duration(2000) - .attr("r", 6) - .transition() - .duration(2000) - .attr("r", 0) - .ease('sine') - .each("end", repeat); - })(); + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; } - }); + } + return nodeTypeLetter; + }) + .style("display", function (d) { + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); - t.selectAll(".WorkflowChart-nameText") - .attr("x", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; - }) - .attr("y", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; - }) - .attr("text-anchor", function (d) { - return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; - }) - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; - }); + t.selectAll(".WorkflowChart-nodeStatus") + .attr("class", function(d) { - t.selectAll(".WorkflowChart-detailsLink") - .style("display", function (d) { - return d.job && d.job.status && d.job.id ? null : "none"; - }); + let statusClass = "WorkflowChart-nodeStatus "; - t.selectAll(".WorkflowChart-deletedText") - .style("display", function (d) { - return d.unifiedJobTemplate || d.placeholder ? "none" : null; - }); + if(d.job){ + switch(d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "canceled": + statusClass += "workflowChart-nodeStatus--canceled"; + break; + } + } - t.selectAll(".WorkflowChart-conflictText") - .style("display", function (d) { - return (d.edgeConflict && !d.placeholder) ? null : "none"; - }); - - t.selectAll(".WorkflowChart-activeNode") - .style("display", function (d) { - return d.isActiveEdit ? null : "none"; - }); - - t.selectAll(".WorkflowChart-elapsed") - .style("display", function (d) { - return (d.job && d.job.elapsed) ? null : "none"; - }); - } else if (!scope.watchDimensionsSet) { - scope.watchDimensionsSet = scope.$watch('dimensionsSet', function () { - if (scope.dimensionsSet) { - scope.watchDimensionsSet(); - scope.watchDimensionsSet = null; - update(); + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .transition() + .duration(0) + .attr("r", 6) + .each(function(d) { + if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { + // Pulse the circle + var circle = d3.select(this); + (function repeat() { + circle = circle.transition() + .duration(2000) + .attr("r", 6) + .transition() + .duration(2000) + .attr("r", 0) + .ease('sine') + .each("end", repeat); + })(); } }); - } - } - function add_node() { - this.on("click", function (d) { - if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: false - }); - } - }); - } - - function add_node_between() { - this.on("click", function (d) { - if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: true - }); - } - }); - } - - function remove_node() { - this.on("click", function (d) { - if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.deleteNode({ - nodeToDelete: d - }); - } - }); - } - - function edit_node() { - this.on("click", function (d) { - if (d.canEdit) { - scope.editNode({ - nodeToEdit: d - }); - } - }); - } - - function details() { - this.on("mouseover", function () { - d3.select(this).style("text-decoration", "underline"); - }); - this.on("mouseout", function () { - d3.select(this).style("text-decoration", null); - }); - this.on("click", function (d) { - - let goToJobResults = function (job_type) { - if (job_type === 'job') { - $state.go('output', { - id: d.job.id, - type: 'playbook' - }); - } else if (job_type === 'inventory_update') { - $state.go('output', { - id: d.job.id, - type: 'inventory' - }); - } else if (job_type === 'project_update') { - $state.go('output', { - id: d.job.id, - type: 'project' - }); - } else if (job_type === 'workflow_job') { - $state.go('workflowResults', { - id: d.job.id, - }); - } - }; - - if (d.job.type) { - goToJobResults(d.job.type); - } - else { - // We don't have access to the job type and have to make - // a GET request in order to find out what type job this was - // so that we can route the user to the correct stdout view - Rest.setUrl(GetBasePath("workflow_jobs") + `${d.originalNodeObj.workflow_job}/workflow_nodes/?order_by=id`); - Rest.get() - .then(function (res) { - if (res.data.results && res.data.results.length > 0) { - const { results } = res.data; - const job = results.filter(result => result.summary_fields.job.id === d.job.id); - goToJobResults(job[0].summary_fields.job.type); - } - }) - .catch(({ - data, - status - }) => { - ProcessErrors(scope, data, status, null, { - hdr: 'Error!', - msg: 'Unable to get job: ' + status - }); - }); - } - }); - } - - scope.$watch('canAddWorkflowJobTemplate', function () { - // Redraw the graph if permissions change - if (scope.treeData) { - update(); - } - }); - - scope.$on('refreshWorkflowChart', function () { - if (scope.treeData) { - update(); - } - }); - - scope.$on('panWorkflowChart', function (evt, params) { - manualPan(params.direction); - }); - - scope.$on('resetWorkflowChart', function () { - resetZoomAndPan(); - }); - - scope.$on('zoomWorkflowChart', function (evt, params) { - manualZoom(params.zoom); - }); - - scope.$on('zoomToFitChart', function () { - zoomToFitChart(); - }); - - let clearWatchTreeData = scope.$watch('treeData', function (newVal) { - if (newVal) { - update(); - clearWatchTreeData(); - } - }); - - function onResize() { - let dimensions = calcAvailableScreenSpace(); - - $('.WorkflowMaker-chart').css("height", dimensions.height); - } - - function cleanUpResize() { - angular.element($window).off('resize', onResize); - } - - if (scope.mode === 'details') { - angular.element($window).on('resize', onResize); - scope.$on('$destroy', cleanUpResize); - - scope.$on('workflowDetailsResized', function () { - $('.WorkflowMaker-chart').hide(); - $timeout(function () { - onResize(); - $('.WorkflowMaker-chart').show(); + t.selectAll(".WorkflowChart-nameText") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; }); - }); - } else { - scope.$on('workflowMakerModalResized', function () { - let dimensions = calcAvailableScreenSpace(); - $('.WorkflowMaker-chart').css("height", dimensions.height); + t.selectAll(".WorkflowChart-detailsLink") + .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); + + t.selectAll(".WorkflowChart-deletedText") + .style("display", function(d){ return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); + + t.selectAll(".WorkflowChart-activeNode") + .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); + + t.selectAll(".WorkflowChart-elapsed") + .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + } + else if(!scope.watchDimensionsSet){ + scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ + if(scope.dimensionsSet) { + scope.watchDimensionsSet(); + scope.watchDimensionsSet = null; + update(); + } }); } } - }; - } -]; + + function add_node() { + this.on("click", function(d) { + if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.addNode({ + parent: d, + betweenTwoNodes: false + }); + } + }); + } + + function add_node_between() { + this.on("click", function(d) { + if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.addNode({ + parent: d, + betweenTwoNodes: true + }); + } + }); + } + + function remove_node() { + this.on("click", function(d) { + if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.deleteNode({ + nodeToDelete: d + }); + } + }); + } + + function edit_node() { + this.on("click", function(d) { + if(d.canEdit){ + scope.editNode({ + nodeToEdit: d + }); + } + }); + } + + function edit_link() { + this.on("click", function(d) { + if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details'){ + // What if the node is new? it won't have a nodeId right? + scope.editLink({ + parentId: d.source.nodeId, + childId: d.target.nodeId + }); + } + }); + } + + function link_node() { + this.on("click", function(d) { + alert('this does not work, don\'t click it'); + }); + } + + function details() { + this.on("mouseover", function() { + d3.select(this).style("text-decoration", "underline"); + }); + this.on("mouseout", function() { + d3.select(this).style("text-decoration", null); + }); + this.on("click", function(d) { + + let goToJobResults = function(job_type) { + if(job_type === 'job') { + $state.go('output', {id: d.job.id, type: 'playbook'}); + } + else if(job_type === 'inventory_update') { + $state.go('output', {id: d.job.id, type: 'inventory'}); + } + else if(job_type === 'project_update') { + $state.go('output', {id: d.job.id, type: 'project'}); + } + }; + + if(d.job.id) { + if(d.unifiedJobTemplate) { + goToJobResults(d.unifiedJobTemplate.unified_job_type); + } + else { + // We don't have access to the unified resource and have to make + // a GET request in order to find out what type job this was + // so that we can route the user to the correct stdout view + + Rest.setUrl(GetBasePath("unified_jobs") + "?id=" + d.job.id); + Rest.get() + .then(function (res) { + if(res.data.results && res.data.results.length > 0) { + goToJobResults(res.data.results[0].type); + } + }) + .catch(({data, status}) => { + ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Unable to get job: ' + status }); + }); + } + } + }); + } + + scope.$watch('canAddWorkflowJobTemplate', function() { + // Redraw the graph if permissions change + if(scope.treeData) { + update(); + } + }); + + scope.$on('refreshWorkflowChart', function(){ + if(scope.treeData) { + update(); + } + }); + + scope.$on('panWorkflowChart', function(evt, params) { + manualPan(params.direction); + }); + + scope.$on('resetWorkflowChart', function(){ + resetZoomAndPan(); + }); + + scope.$on('zoomWorkflowChart', function(evt, params) { + manualZoom(params.zoom); + }); + + scope.$on('zoomToFitChart', function() { + zoomToFitChart(); + }); + + let clearWatchTreeData = scope.$watch('treeData', function(newVal) { + if(newVal) { + update(); + clearWatchTreeData(); + } + }); + + function onResize(){ + let dimensions = calcAvailableScreenSpace(); + + $('.WorkflowMaker-chart').css("width", dimensions.width); + $('.WorkflowMaker-chart').css("height", dimensions.height); + } + + function cleanUpResize() { + angular.element($window).off('resize', onResize); + } + + if(scope.mode === 'details') { + angular.element($window).on('resize', onResize); + scope.$on('$destroy', cleanUpResize); + + scope.$on('workflowDetailsResized', function(){ + $('.WorkflowMaker-chart').hide(); + $timeout(function(){ + onResize(); + $('.WorkflowMaker-chart').show(); + }); + }); + } + else { + scope.$on('workflowMakerModalResized', function(){ + let dimensions = calcAvailableScreenSpace(); + + $('.WorkflowMaker-chart').css("width", dimensions.width); + $('.WorkflowMaker-chart').css("height", dimensions.height); + }); + } + } + }; +}]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js new file mode 100644 index 0000000000..426eaa131c --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/main.js @@ -0,0 +1,7 @@ +import workflowLinkForm from './workflow-link-form.directive'; +import workflowNodeForm from './workflow-node-form.directive'; + +export default + angular.module('templates.workflowMaker.forms', []) + .directive('workflowLinkForm', workflowLinkForm) + .directive('workflowNodeForm', workflowNodeForm); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js new file mode 100644 index 0000000000..7c95cd97dd --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js @@ -0,0 +1,38 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$scope', 'TemplatesStrings', 'CreateSelect2', '$timeout', + function($scope, TemplatesStrings, CreateSelect2, $timeout) { + $scope.strings = TemplatesStrings; + + $scope.edgeTypeOptions = [ + { + label: $scope.strings.get('workflow_maker.ALWAYS'), + value: 'always' + }, + { + label: $scope.strings.get('workflow_maker.ON_SUCCESS'), + value: 'success' + }, + { + label: $scope.strings.get('workflow_maker.ON_FAILURE'), + value: 'failure' + } + ]; + + $scope.$watch('linkConfig.edgeType', () => { + if (_.has($scope, 'linkConfig.edgeType')) { + $scope.edgeType = { + value: $scope.linkConfig.edgeType + }; + CreateSelect2({ + element: '#workflow_node_edge_2', + multiple: false + }); + } + }); + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js new file mode 100644 index 0000000000..00b3afc765 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js @@ -0,0 +1,22 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowLinkFormController from './workflow-link-form.controller'; + +export default ['templateUrl', + function(templateUrl) { + return { + scope: { + linkConfig: '<', + cancel: '&', + select: '&' + }, + restrict: 'E', + templateUrl: templateUrl('templates/workflows/workflow-maker/forms/workflow-link-form'), + controller: workflowLinkFormController + }; + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html new file mode 100644 index 0000000000..cf03f1624c --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -0,0 +1,25 @@ +
{{:: strings.get('workflow_maker.EDIT_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) }}
+
+
+ +
+ +
+
+
+ + +
+
+ diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js new file mode 100644 index 0000000000..41e8b11142 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$scope', + function($scope) { + console.log('inside wnf controller'); + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js new file mode 100644 index 0000000000..197b6ae86b --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js @@ -0,0 +1,21 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowNodeFormController from './workflow-node-form.controller'; + +export default ['templateUrl', + function(templateUrl) { + return { + scope: {}, + restrict: 'E', + templateUrl: templateUrl('templates/workflows/workflow-maker/forms/workflow-node-form'), + controller: workflowNodeFormController, + link: function(scope) { + console.log('inside link function for workflow node form'); + } + }; + } +]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html new file mode 100644 index 0000000000..c35a26575a --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -0,0 +1,51 @@ +
+
{{strings.get('workflow_maker.JOBS')}}
+
{{strings.get('workflow_maker.PROJECT_SYNC')}}
+
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
+
+
+
+
+
+
+ +
+
+ + {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} +
+
+
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+
+ +
+ +
+
+
+ + + + +
+
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/main.js b/awx/ui/client/src/templates/workflows/workflow-maker/main.js index 821dfe18aa..f93a952b82 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/main.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/main.js @@ -1,8 +1,9 @@ import workflowMaker from './workflow-maker.directive'; import WorkflowMakerController from './workflow-maker.controller'; +import workflowMakerForms from './forms/main'; export default - angular.module('templates.workflowMaker', []) + angular.module('templates.workflowMaker', [workflowMakerForms.name]) // In order to test this controller I had to expose it at the module level // like so. Is this correct? Is there a better pattern for doing this? .controller('WorkflowMakerController', WorkflowMakerController) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index f1e3671d1c..580d1aa231 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -127,7 +127,6 @@ color: @list-title-txt; font-size: 14px; font-weight: bold; - text-transform: uppercase; margin-bottom: 20px; } .WorkflowMaker-formHelp { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 8a8258220b..64b3ef3334 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -203,7 +203,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }); }); } else { - if (params.node.edited || !params.node.originalParentId || (params.node.originalParentId && params.parentId !== params.node.originalParentId)) { + if (params.node.edited || !params.node.originalParentId || (params.node.originalParentId && (params.parentId !== params.node.originalParentId || params.node.originalEdge !== params.node.edgeType))) { if (params.node.edited) { @@ -446,6 +446,10 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.cancelNodeForm(); } + if ($scope.linkBeingEdited) { + $scope.cancelLinkForm(); + } + $scope.workflowMakerFormConfig.nodeMode = "add"; $scope.addParent = parent; $scope.betweenTwoNodes = betweenTwoNodes; @@ -572,6 +576,10 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.startEditNode = function (nodeToEdit) { $scope.editNodeHelpMessage = null; + if ($scope.linkBeingEdited) { + $scope.cancelLinkForm(); + } + if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { if ($scope.placeholderNode || $scope.nodeBeingEdited) { $scope.cancelNodeForm(); @@ -893,6 +901,91 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }; + /* EDIT LINK FUNCTIONS */ + + $scope.startEditLink = (parentId, childId) => { + const setupLinkEdit = () => { + const parentNode = WorkflowService.searchTree({ + element: $scope.treeData.data, + matchingId: parentId, + byNodeId: true + }); + + parentNode.isLinkEditParent = true; + + // Loop across children looking for childId + const childNode = _.find(parentNode.children, {'nodeId': childId}); + + childNode.isLinkEditChild = true; + + $scope.linkBeingEdited = { + parent: parentNode, + child: childNode + } + + $scope.linkConfig = { + parent: { + id: parentId, + name: parentNode.unifiedJobTemplate.name + }, + child: { + id: childId, + name: childNode.unifiedJobTemplate.name + }, + edgeType: childNode.edgeType + } + $scope.editLink = true; + + $scope.$broadcast("refreshWorkflowChart"); + } + + if ($scope.nodeBeingEdited || $scope.placeholderNode) { + $scope.cancelNodeForm(); + } + + if ($scope.linkBeingEdited) { + if ($scope.linkBeingEdited.parent.nodeId !== parentId || $scope.linkBeingEdited.child.nodeId !== childId) { + $scope.linkBeingEdited.parent.isLinkEditParent = false; + $scope.linkBeingEdited.child.isLinkEditChild = false; + setupLinkEdit() + } + } else { + setupLinkEdit(); + } + + }; + + $scope.confirmLinkForm = (parentId, childId, edgeType) => { + $scope.linkBeingEdited.parent.isLinkEditParent = false; + $scope.linkBeingEdited.child.isLinkEditChild = false; + const parentNode = WorkflowService.searchTree({ + element: $scope.treeData.data, + matchingId: parentId, + byNodeId: true + }); + + // Loop across children looking for childId + const childNode = _.find(parentNode.children, {'nodeId': childId}); + + childNode.edgeType = edgeType; + + $scope.linkBeingEdited = null; + + $scope.editLink = false; + + $scope.$broadcast("refreshWorkflowChart"); + } + + $scope.cancelLinkForm = () => { + $scope.linkBeingEdited.parent.isLinkEditParent = false; + $scope.linkBeingEdited.child.isLinkEditChild = false; + $scope.linkBeingEdited = null; + + $scope.editLink = false; + + $scope.$broadcast("refreshWorkflowChart"); + }; + /* DELETE NODE FUNCTIONS */ function resetDeleteNode() { @@ -912,6 +1005,10 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.confirmDeleteNode = function () { if ($scope.nodeToBeDeleted) { + if ($scope.linkBeingEdited) { + $scope.cancelLinkForm(); + } + // TODO: turn this into a promise so that we can handle errors WorkflowService.removeNodeFromTree({ diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 7760f5b7aa..b7c18669e5 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -81,68 +81,69 @@ - +
-
{{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited) ? ((nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : strings.get('workflow_maker.EDIT_TEMPLATE')) : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
-
-
-
-
{{strings.get('workflow_maker.JOBS')}}
-
{{strings.get('workflow_maker.PROJECT_SYNC')}}
-
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
+ +
{{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited) ? ((nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : strings.get('workflow_maker.EDIT_TEMPLATE')) : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
+
+
+
+
{{strings.get('workflow_maker.JOBS')}}
+
{{strings.get('workflow_maker.PROJECT_SYNC')}}
+
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
+
+
+
+
+
+
+ +
+
+ + {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} +
+
+
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+
+ +
+ +
+
+
+ + + + +
+
-
-
-
-
-
- -
-
- - {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} -
-
-
-
- - {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} -
-
-
- -
- -
-
-
-
-
- - - - -
-
-
+ + + +
From 29b4979736d1f827420459988be667836784a25c Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 24 Oct 2018 15:32:28 -0400 Subject: [PATCH 23/99] Completed work necessary to support editing workflow links and nodes separately. Added hover and tooltip to links --- .../features/templates/templates.strings.js | 5 +- .../workflow-chart/workflow-chart.block.less | 187 ++-- .../workflow-chart.directive.js | 305 ++++--- .../forms/workflow-link-form.directive.js | 1 + .../forms/workflow-link-form.partial.html | 8 +- .../forms/workflow-node-form.controller.js | 633 +++++++++++++- .../forms/workflow-node-form.directive.js | 13 +- .../forms/workflow-node-form.partial.html | 139 ++- .../workflow-maker/workflow-maker.block.less | 46 + .../workflow-maker.controller.js | 824 ++---------------- .../workflow-maker.partial.html | 65 +- .../workflow-results.block.less | 7 + 12 files changed, 1174 insertions(+), 1059 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 785d9902cc..5e8afaf8bd 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -111,6 +111,8 @@ function TemplatesStrings (BaseString) { JOBS: t.s('JOBS'), PLEASE_CLICK_THE_START_BUTTON: t.s('Please click the start button to build your workflow.'), PLEASE_HOVER_OVER_A_TEMPLATE: t.s('Please hover over a template for additional options.'), + EDIT_LINK_TOOLTIP: t.s('Click to edit link'), + VIEW_LINK_TOOLTIP: t.s('Click to view link'), RUN: t.s('RUN'), CHECK: t.s('CHECK'), SELECT: t.s('SELECT'), @@ -122,7 +124,8 @@ function TemplatesStrings (BaseString) { INVENTORY_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden by the parent workflow inventory.'), INVENTORY_PROMPT_WILL_OVERRIDE: t.s('The inventory of this node will be overridden if a parent workflow inventory is provided at launch.'), INVENTORY_PROMPT_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden if a parent workflow inventory is provided at launch.'), - EDIT_LINK: ({ parentName, childName }) => t.s('EDIT LINK | {{parentName}} to {{childName}}', { parentName, childName }) + EDIT_LINK: ({ parentName, childName }) => t.s('EDIT LINK | {{parentName}} to {{childName}}', { parentName, childName }), + VIEW_LINK: ({ parentName, childName }) => t.s('VIEW LINK | {{parentName}} to {{childName}}', { parentName, childName }) } } diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index f5e9bd7378..b2deec2bf3 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -1,43 +1,88 @@ -.link circle, -.link polygon, -.link .linkCross, -.node circle, -.node .linkIcon, -.node .WorkflowChart-hoverPath { +.WorkflowChart-node { + font-size: 12px; + font-family: 'Open Sans', sans-serif, 'FontAwesome'; +} + +.WorkflowChart-link { + fill: none; + stroke-width: 2px; +} + +.WorkflowChart-linkOverlay { + fill: @default-interface-txt; +} + +.WorkflowChart-link--active.WorkflowChart-linkOverlay, +.WorkflowChart-linkHovering .WorkflowChart-linkOverlay { + cursor: pointer; + opacity: 1; + fill: @cgrey; +} + +.WorkflowChart-linkHovering .WorkflowChart-linkPath { + cursor: pointer; +} + +.WorkflowChart-link circle, +.WorkflowChart-link polygon, +.WorkflowChart-link .WorkflowChart-betweenNodesIcon, +.WorkflowChart-node .WorkflowChart-nodeAddCircle, +.WorkflowChart-node .WorkflowChart-nodeRemoveCircle, +.WorkflowChart-node .WorkflowChart-nodeAddIcon, +.WorkflowChart-node .WorkflowChart-nodeRemoveIcon { opacity: 0; } -.node .addCircle, .link .addCircle { +.WorkflowChart-node .WorkflowChart-addCircle, .WorkflowChart-link .WorkflowChart-addCircle { fill: @default-succ; } -.addCircle.addHovering { +.WorkflowChart-addCircle.WorkflowChart-addHovering { fill: @default-succ-hov; } -.node .removeCircle { +.WorkflowChart-node .WorkflowChart-nodeRemoveCircle { fill: @default-err; } -.removeCircle.removeHovering { +.WorkflowChart-nodeRemoveCircle.removeHovering { fill: @default-err-hov; } -.node .linkCircle { - fill: @default-link; +.WorkflowChart-node .WorkflowChart-rect { + fill: @default-secondary-bg; } -.node .linkIcon { - color: @default-bg; +.WorkflowChart-rect.WorkflowChart-placeholder { + stroke-dasharray: 3; } -.linkCircle.removeHovering { - fill: @default-link-hov; +.WorkflowChart-node .WorkflowChart-transparentRect { + fill: @default-bg; + opacity: 0; } -.node { - font-size: 12px; - font-family: 'Open Sans', sans-serif, 'FontAwesome'; +.WorkflowChart-alwaysShowAdd circle, +.WorkflowChart-alwaysShowAdd path, +.WorkflowChart-alwaysShowAdd .WorkflowChart-betweenNodesIcon, +.WorkflowChart-nodeHovering .WorkflowChart-nodeAddCircle, +.WorkflowChart-nodeHovering .WorkflowChart-nodeAddIcon, +.WorkflowChart-nodeHovering .WorkflowChart-nodeRemoveCircle, +.WorkflowChart-nodeHovering .WorkflowChart-nodeRemoveIcon, +.WorkflowChart-addHovering circle, +.WorkflowChart-addHovering path, +.WorkflowChart-addHovering .WorkflowChart-betweenNodesIcon { + cursor: pointer; + opacity: 1; +} + +.WorkflowChart-link.WorkflowChart-placeholder { + stroke-dasharray: 3; +} + +.WorkflowChart-svg { + border-bottom-left-radius: 5px; + width: 100%; } .WorkflowChart-defaultText { @@ -49,76 +94,36 @@ cursor: default; } -.node .rect { - fill: @default-secondary-bg; -} - -.rect.placeholder { - stroke-dasharray: 3; -} - -.node .transparentRect { - fill: @default-bg; - opacity: 0; -} - -.WorkflowChart-alwaysShowAdd circle, -.WorkflowChart-alwaysShowAdd path, -.WorkflowChart-alwaysShowAdd .linkCross, -.hovering .addCircle, -.hovering .removeCircle, -.addHovering .betweenNodesCircle, -.hovering .linkCircle, -.hovering .linkIcon, -.hovering .WorkflowChart-hoverPath, -.addHovering .linkCross { - cursor: pointer; - opacity: 1; -} - -.link { - fill: none; - stroke-width: 2px; -} - -.link.placeholder { - stroke-dasharray: 3; -} - -.WorkflowChart-svg { - border-bottom-left-radius: 5px; - width: 100%; -} - -.WorkflowResults-rightSide .WorkflowChart-svg { - background-color: @f6grey; - border: 1px solid @d7grey; - border-top: 0px; - border-bottom-right-radius: 5px; -} .WorkflowChart-nodeTypeCircle { fill: @default-icon; } + .WorkflowChart-nodeTypeLetter { fill: @default-bg; } -.workflowChart-nodeStatus--running { + +.WorkflowChart-nodeStatus--running { fill: @default-icon; } -.workflowChart-nodeStatus--success { + +.WorkflowChart-nodeStatus--success { fill: @default-succ; } -.workflowChart-nodeStatus--failed, .workflowChart-nodeStatus--canceled { + +.WorkflowChart-nodeStatus--failed, .WorkflowChart-nodeStatus--canceled { fill: @default-err; } + .WorkflowChart-detailsLink { fill: @default-link; cursor: pointer; font-size: 10px; } + .WorkflowChart-incompleteIcon { color: @default-warning; } + .WorkflowChart-deletedText { width: 90px; color: @default-interface-txt; @@ -126,6 +131,7 @@ .WorkflowChart-activeNode { fill: @default-link; } + .WorkflowChart-elapsedHolder { background-color: @b7grey; color: @default-bg; @@ -134,40 +140,47 @@ padding: 1px 3px; border-radius: 4px; } + .WorkflowChart-nameText { font-size: 10px; } +.WorkflowChart-tooltip { + pointer-events: none; + text-align: center; +} + .WorkflowChart-tooltipContents { padding: 10px; - background-color: #707070; - color: #FFFFFF; + background-color: @default-interface-txt; + color: @default-bg; border-radius: 4px; word-wrap: break-word; max-width: 325px; + font-size: 10px; } -.WorkflowChart-tooltipArrow { + +.WorkflowChart-tooltipArrow--down { width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; - border-top: 10px solid #707070; + border-top: 10px solid @default-interface-txt; margin: auto; } + +.WorkflowChart-tooltipArrow--right { + width: 0; + height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 10px solid @default-interface-txt; + margin: auto; + position: relative; + right: -55px; + top: -34px; +} + .WorkflowChart-dashedNode { stroke-dasharray: 5,5; } - -.linkOverlay { - fill: @default-interface-txt; -} - -.linkActiveEdit.linkOverlay, -.overlayHovering .linkOverlay { - cursor: pointer; - opacity: 0.4; -} - -.overlayHovering .linkPath { - cursor: pointer; -} diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index bbf6610e3b..59feb22066 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -239,14 +239,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let nodes = tree.nodes(scope.treeData), links = tree.links(nodes); - let node = svgGroup.selectAll("g.node") + let node = svgGroup.selectAll("g.WorkflowChart-node") .data(nodes, function(d) { d.y = d.depth * 240; return d.id || (d.id = ++i); }); let nodeEnter = node.enter().append("g") - .attr("class", "node") + .attr("class", "WorkflowChart-node") .attr("id", function(d){return "node-" + d.id;}) .attr("parent", function(d){return d.parent ? d.parent.id : null;}) .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); @@ -308,7 +308,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .attr('stroke-width', "2px") .attr("class", function(d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; + let classString = d.placeholder ? "WorkflowChart-rect WorkflowChart-placeholder" : "WorkflowChart-rect"; classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; return classString; }); @@ -398,7 +398,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) - .attr("class", "transparentRect") + .attr("class", "WorkflowChart-transparentRect") .call(edit_node) .on("mouseover", function(d) { if(!d.isStartNode) { @@ -409,13 +409,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top // of the links and not underneath them. I tried rendering the links before the nodes but that lead to // some weird link animation that I didn't care to try to fix. - svgGroup.selectAll("g.node").each(function() { + svgGroup.selectAll("g.WorkflowChart-node").each(function() { this.parentNode.appendChild(this); }); // After the nodes have been properly placed after the links, we need to make sure that the node that // the user is hovering over is at the very end of the list. This way the tooltip will appear on top // of all other nodes. - svgGroup.selectAll("g.node").sort(function (a) { + svgGroup.selectAll("g.WorkflowChart-node").sort(function (a) { return (a.id !== d.id) ? -1 : 1; }); // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place @@ -437,14 +437,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); } d3.select("#node-" + d.id) - .classed("hovering", true); + .classed("WorkflowChart-nodeHovering", true); } }) .on("mouseout", function(d){ $('.WorkflowChart-tooltip').remove(); if(!d.isStartNode) { d3.select("#node-" + d.id) - .classed("hovering", false); + .classed("WorkflowChart-nodeHovering", false); } }); thisNode.append("text") @@ -461,23 +461,23 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "node-" + d.id + "-add";}) .attr("cx", nodeW) .attr("r", 10) - .attr("class", "addCircle nodeCircle") + .attr("class", "WorkflowChart-addCircle WorkflowChart-nodeAddCircle") .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) .call(add_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) - .classed("hovering", true); + .classed("WorkflowChart-nodeHovering", true); d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); + .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ d3.select("#node-" + d.id) - .classed("hovering", false); + .classed("WorkflowChart-nodeHovering", false); d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); + .classed("WorkflowChart-addHovering", false); }); thisNode.append("path") - .attr("class", "nodeAddCross WorkflowChart-hoverPath") + .attr("class", "WorkflowChart-nodeAddIcon") .style("fill", "white") .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) .attr("d", d3.svg.symbol() @@ -488,38 +488,38 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .call(add_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) - .classed("hovering", true); + .classed("WorkflowChart-nodeHovering", true); d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); + .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ d3.select("#node-" + d.id) - .classed("hovering", false); + .classed("WorkflowChart-nodeHovering", false); d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); + .classed("WorkflowChart-addHovering", false); }); thisNode.append("circle") .attr("id", function(d){return "node-" + d.id + "-remove";}) .attr("cx", nodeW) .attr("cy", nodeH) .attr("r", 10) - .attr("class", "removeCircle") + .attr("class", "WorkflowChart-nodeRemoveCircle") .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) - .classed("hovering", true); + .classed("WorkflowChart-nodeHovering", true); d3.select("#node-" + d.id + "-remove") .classed("removeHovering", true); }) .on("mouseout", function(d){ d3.select("#node-" + d.id) - .classed("hovering", false); + .classed("WorkflowChart-nodeHovering", false); d3.select("#node-" + d.id + "-remove") .classed("removeHovering", false); }); thisNode.append("path") - .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") + .attr("class", "WorkflowChart-nodeRemoveIcon") .style("fill", "white") .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) .attr("d", d3.svg.symbol() @@ -530,60 +530,16 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) - .classed("hovering", true); + .classed("WorkflowChart-nodeHovering", true); d3.select("#node-" + d.id + "-remove") .classed("removeHovering", true); }) .on("mouseout", function(d){ d3.select("#node-" + d.id) - .classed("hovering", false); + .classed("WorkflowChart-nodeHovering", false); d3.select("#node-" + d.id + "-remove") .classed("removeHovering", false); }); - // thisNode.append("circle") - // .attr("id", function(d){return "node-" + d.id + "-link";}) - // .attr("cx", nodeW) - // .attr("cy", nodeH/2) - // .attr("r", 10) - // .attr("class", "linkCircle nodeCircle") - // .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - // .call(link_node) - // .on("mouseover", function(d) { - // d3.select("#node-" + d.id) - // .classed("hovering", true); - // d3.select("#node-" + d.id + "-link") - // .classed("addHovering", true); - // }) - // .on("mouseout", function(d){ - // d3.select("#node-" + d.id) - // .classed("hovering", false); - // d3.select("#node-" + d.id + "-link") - // .classed("addHovering", false); - // }); - // // TODO: clean up the placement of this icon... this works but it's not - // // clean - // thisNode.append("foreignObject") - // .attr("x", nodeW - 6) - // .attr("y", nodeH/2 - 9) - // .style("font-size","14px") - // .html(function () { - // return ``; - // }) - // .attr("class", "linkIcon") - // .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - // .call(link_node) - // .on("mouseover", function(d) { - // d3.select("#node-" + d.id) - // .classed("hovering", true); - // d3.select("#node-" + d.id + "-link") - // .classed("addHovering", true); - // }) - // .on("mouseout", function(d){ - // d3.select("#node-" + d.id) - // .classed("hovering", false); - // d3.select("#node-" + d.id + "-link") - // .classed("addHovering", false); - // }); thisNode.append("circle") .attr("class", function(d) { @@ -593,25 +549,25 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge if(d.job){ switch(d.job.status) { case "pending": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "waiting": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "running": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "successful": - statusClass += "workflowChart-nodeStatus--success"; + statusClass += "WorkflowChart-nodeStatus--success"; break; case "failed": - statusClass += "workflowChart-nodeStatus--failed"; + statusClass += "WorkflowChart-nodeStatus--failed"; break; case "error": - statusClass += "workflowChart-nodeStatus--failed"; + statusClass += "WorkflowChart-nodeStatus--failed"; break; case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; + statusClass += "WorkflowChart-nodeStatus--canceled"; break; } } @@ -652,63 +608,149 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge graphLoaded = true; - let link = svgGroup.selectAll("g.link") + let link = svgGroup.selectAll("g.WorkflowChart-link") .data(links, function(d) { return d.source.id + "-" + d.target.id; }); let linkEnter = link.enter().append("g") - .attr("class", "link") + .attr("class", "WorkflowChart-link") .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); linkEnter.append("polygon", "g") .attr("class", function(d) { - let linkClasses = ["linkOverlay"]; + let linkClasses = ["WorkflowChart-linkOverlay"]; if (d.source.isLinkEditParent && d.target.isLinkEditChild) { - linkClasses.push("linkActiveEdit"); + linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); }) .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) .attr("points",function(d) { - const pt1 = [d.source.y + nodeW, d.source.x + 10 + nodeH/2].join(","); - const pt2 = [d.target.y,d.target.x + 10 + nodeH/2].join(","); - const pt3 = [d.target.y,d.target.x - 10 + nodeH/2].join(","); - const pt4 = [d.source.y + nodeW,d.source.x - 10 + nodeH/2].join(","); + let x1 = d.source.y + nodeW; + let y1 = d.source.x + nodeH / 2; + let x2 = d.target.y; + let y2 = d.target.x + nodeH / 2; + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + return [pt1, pt2, pt3, pt4].join(" "); }) .call(edit_link) .on("mouseover", function(d) { - if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details') { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("overlayHovering", true); + .classed("WorkflowChart-linkHovering", true); + + let xPos, yPos, arrowClass; + if (d.source.x === d.target.x) { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + arrowClass = 'WorkflowChart-tooltipArrow--down'; + } else { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + arrowClass = 'WorkflowChart-tooltipArrow--right'; + } + + let edgeTypeLabel; + + switch(d.target.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + + let linkInstructionText = _.get(scope, 'workflowJobTemplateObj.summary_fields.user_capabilities.edit') ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + + linkEnter.append("foreignObject") + .attr("x", xPos) + .attr("y", yPos) + .attr("width", 100) + .attr("height", 60) + .attr("class", "WorkflowChart-tooltip") + .html(function(){ + return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; + }); } + }) .on("mouseout", function(d){ if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("overlayHovering", false); + .classed("WorkflowChart-linkHovering", false); } + $('.WorkflowChart-tooltip').remove(); }); // Add entering links in the parent’s old position. linkEnter.append("path", "g") .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + return (d.source.placeholder || d.target.placeholder) ? "WorkflowChart-linkPath WorkflowChart-placeholder" : "WorkflowChart-linkPath"; }) .attr("d", lineData) .call(edit_link) - .on("mouseover", function(d) { - if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { + .on("mouseenter", function(d) { + if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details') { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("overlayHovering", true); + .classed("WorkflowChart-linkHovering", true); + + let xPos, yPos, arrowClass; + if (d.source.x === d.target.x) { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + arrowClass = 'WorkflowChart-tooltipArrow--down'; + } else { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + arrowClass = 'WorkflowChart-tooltipArrow--right'; + } + + let edgeTypeLabel; + + switch(d.target.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + + let linkInstructionText = _.get(scope, 'workflowJobTemplateObj.summary_fields.user_capabilities.edit') ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + + linkEnter.append("foreignObject") + .attr("x", xPos) + .attr("y", yPos) + .attr("width", 100) + .attr("height", 60) + .attr("class", "WorkflowChart-tooltip") + .html(function(){ + return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; + }); } }) - .on("mouseout", function(d){ + .on("mouseleave", function(d){ if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("overlayHovering", false); + .classed("WorkflowChart-linkHovering", false); } + $('.WorkflowChart-tooltip').remove(); }) .attr('stroke', function(d) { if(d.target.edgeType) { @@ -736,20 +778,20 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; }) .attr("r", 10) - .attr("class", "addCircle betweenNodesCircle") + .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) .call(add_node_between) .on("mouseover", function(d) { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("addHovering", true); + .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("addHovering", false); + .classed("WorkflowChart-addHovering", false); }); linkEnter.append("path") - .attr("class", "linkCross") + .attr("class", "WorkflowChart-betweenNodesIcon") .style("fill", "white") .attr("transform", function(d) { let translate; @@ -769,11 +811,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .call(add_node_between) .on("mouseover", function(d) { d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("addHovering", true); + .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("addHovering", false); + .classed("WorkflowChart-addHovering", false); }); link.exit().remove(); @@ -781,21 +823,21 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // Transition nodes and links to their new positions. let t = baseSvg.transition(); - t.selectAll(".nodeCircle") + t.selectAll(".WorkflowChart-nodeAddCircle") .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - t.selectAll(".nodeAddCross") + t.selectAll(".WorkflowChart-nodeAddIcon") .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - t.selectAll(".removeCircle") + t.selectAll(".WorkflowChart-nodeRemoveCircle") .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - t.selectAll(".nodeRemoveCross") + t.selectAll(".WorkflowChart-nodeRemoveIcon") .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - t.selectAll(".linkPath") + t.selectAll(".WorkflowChart-linkPath") .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + return (d.source.placeholder || d.target.placeholder) ? "WorkflowChart-linkPath WorkflowChart-placeholder" : "WorkflowChart-linkPath"; }) .attr("d", lineData) .attr('stroke', function(d) { @@ -815,7 +857,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }); - t.selectAll(".betweenNodesCircle") + t.selectAll(".WorkflowChart-circleBetweenNodes") + .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) .attr("cx", function(d) { return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; }) @@ -823,23 +866,32 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; }); - t.selectAll(".linkOverlay") + t.selectAll(".WorkflowChart-linkOverlay") .attr("class", function(d) { - let linkClasses = ["linkOverlay"]; + let linkClasses = ["WorkflowChart-linkOverlay"]; if (d.source.isLinkEditParent && d.target.isLinkEditChild) { - linkClasses.push("linkActiveEdit"); + linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); }) .attr("points",function(d) { - const pt1 = [d.source.y + nodeW, d.source.x + 10 + nodeH/2].join(","); - const pt2 = [d.target.y,d.target.x + 10 + nodeH/2].join(","); - const pt3 = [d.target.y,d.target.x - 10 + nodeH/2].join(","); - const pt4 = [d.source.y + nodeW,d.source.x - 10 + nodeH/2].join(","); + let x1 = d.source.y + nodeW; + let y1 = d.source.x + nodeH / 2; + let x2 = d.target.y; + let y2 = d.target.x + nodeH / 2; + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + return [pt1, pt2, pt3, pt4].join(" "); }); - t.selectAll(".linkCross") + t.selectAll(".WorkflowChart-betweenNodesIcon") .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) .attr("transform", function(d) { let translate; @@ -852,7 +904,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return translate; }); - t.selectAll(".rect") + t.selectAll(".WorkflowChart-rect") .attr('stroke', function(d) { if(d.job && d.job.status) { if(d.job.status === "successful"){ @@ -870,12 +922,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }) .attr("class", function(d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; + let classString = d.placeholder ? "WorkflowChart-rect WorkflowChart-placeholder" : "WorkflowChart-rect"; classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; return classString; }); - t.selectAll(".node") + t.selectAll(".WorkflowChart-node") .attr("parent", function(d){return d.parent ? d.parent.id : null;}) .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); @@ -937,25 +989,25 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge if(d.job){ switch(d.job.status) { case "pending": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "waiting": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "running": - statusClass += "workflowChart-nodeStatus--running"; + statusClass += "WorkflowChart-nodeStatus--running"; break; case "successful": - statusClass += "workflowChart-nodeStatus--success"; + statusClass += "WorkflowChart-nodeStatus--success"; break; case "failed": - statusClass += "workflowChart-nodeStatus--failed"; + statusClass += "WorkflowChart-nodeStatus--failed"; break; case "error": - statusClass += "workflowChart-nodeStatus--failed"; + statusClass += "WorkflowChart-nodeStatus--failed"; break; case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; + statusClass += "WorkflowChart-nodeStatus--canceled"; break; } } @@ -1058,11 +1110,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function edit_link() { this.on("click", function(d) { - if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details'){ - // What if the node is new? it won't have a nodeId right? + if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details'){ scope.editLink({ - parentId: d.source.nodeId, - childId: d.target.nodeId + parentId: d.source.id, + childId: d.target.id }); } }); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js index 00b3afc765..8591b9a728 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js @@ -11,6 +11,7 @@ export default ['templateUrl', return { scope: { linkConfig: '<', + readOnly: '<', cancel: '&', select: '&' }, diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html index cf03f1624c..a09dcd0618 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -1,4 +1,4 @@ -
{{:: strings.get('workflow_maker.EDIT_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) }}
+
{{readOnly ? strings.get('workflow_maker.VIEW_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) : strings.get('workflow_maker.EDIT_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) }}
- - + + +
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 41e8b11142..2310c067bf 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -4,8 +4,635 @@ * All Rights Reserved *************************************************/ -export default ['$scope', - function($scope) { - console.log('inside wnf controller'); +export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', + 'WorkflowService', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', + 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', + function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, + WorkflowService, TemplatesStrings, CreateSelect2, Empty, generateList, qs, + GetBasePath, TemplateList, ProjectList, InventorySourcesList + ) { + + let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; + + $scope.strings = TemplatesStrings; + + let templateList = _.cloneDeep(TemplateList); + delete templateList.actions; + delete templateList.fields.type; + delete templateList.fields.description; + delete templateList.fields.smart_status; + delete templateList.fields.labels; + delete templateList.fieldActions; + templateList.fields.name.columnClass = "col-md-8"; + templateList.disableRow = "{{ readOnly }}"; + templateList.disableRowValue = 'readOnly'; + templateList.fields.info = { + ngInclude: "'/static/partials/job-template-details.html'", + type: 'template', + columnClass: 'col-md-3', + label: '', + nosort: true + }; + templateList.maxVisiblePages = 5; + templateList.searchBarFullWidth = true; + $scope.templateList = templateList; + + let inventorySourceList = _.cloneDeep(InventorySourcesList); + inventorySourceList.maxVisiblePages = 5; + inventorySourceList.searchBarFullWidth = true; + inventorySourceList.disableRow = "{{ readOnly }}"; + inventorySourceList.disableRowValue = 'readOnly'; + $scope.inventorySourceList = inventorySourceList; + + let projectList = _.cloneDeep(ProjectList); + delete projectList.fields.status; + delete projectList.fields.scm_type; + delete projectList.fields.last_updated; + projectList.fields.name.columnClass = "col-md-11"; + projectList.maxVisiblePages = 5; + projectList.searchBarFullWidth = true; + projectList.disableRow = "{{ readOnly }}"; + projectList.disableRowValue = 'readOnly'; + $scope.projectList = projectList; + + $scope.$watch('node', (newNode, oldNode) => { + if (oldNode.id !== newNode.id) { + setupNodeForm(); + } + }); + + $scope.$watchGroup(['templates', 'projects', 'inventory_sources', 'activeTab'], () => { + // TODO: make this more concise + switch($scope.activeTab) { + case 'jobs': + $scope.templates.forEach(function(row, i) { + if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { + $scope.templates[i].checked = 1; + } + else { + $scope.templates[i].checked = 0; + } + }); + break; + case 'project_syncs': + $scope.projects.forEach(function(row, i) { + if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { + $scope.projects[i].checked = 1; + } + else { + $scope.projects[i].checked = 0; + } + }); + break; + case 'inventory_syncs': + $scope.inventory_sources.forEach(function(row, i) { + if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { + $scope.inventory_sources[i].checked = 1; + } + else { + $scope.inventory_sources[i].checked = 0; + } + }); + break; + } + }); + + const checkCredentialsForRequiredPasswords = () => { + let credentialRequiresPassword = false; + $scope.promptData.prompts.credentials.value.forEach((credential) => { + if ((credential.passwords_needed && + credential.passwords_needed.length > 0) || + (_.has(credential, 'inputs.vault_password') && + credential.inputs.vault_password === "ASK") + ) { + credentialRequiresPassword = true; + } + }); + $scope.credentialRequiresPassword = credentialRequiresPassword; + }; + + const watchForPromptChanges = () => { + let promptDataToWatch = [ + 'promptData.prompts.inventory.value', + 'promptData.prompts.verbosity.value', + 'missingSurveyValue' + ]; + + promptWatcher = $scope.$watchGroup(promptDataToWatch, function() { + let missingPromptValue = false; + if ($scope.missingSurveyValue) { + missingPromptValue = true; + } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + missingPromptValue = true; + } + $scope.promptModalMissingReqFields = missingPromptValue; + }); + + if ($scope.promptData.launchConf.ask_credential_on_launch && $scope.credentialRequiresPassword) { + credentialsWatcher = $scope.$watch('promptData.prompts.credentials', () => { + checkCredentialsForRequiredPasswords(); + }); + } + }; + + const finishConfiguringEdit = () => { + + let jobTemplate = new JobTemplate(); + + console.log($scope.node); + + if (!_.isEmpty($scope.node.promptData)) { + $scope.promptData = _.cloneDeep($scope.node.promptData); + const launchConf = $scope.promptData.launchConf; + + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; + } else { + $scope.showPromptButton = true; + + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'node.originalNodeObj.summary_fields.inventory')) { + $scope.promptModalMissingReqFields = true; + } else { + $scope.promptModalMissingReqFields = false; + } + } + $scope.nodeFormDataLoaded = true; + } else if ( + _.get($scope, 'node.unifiedJobTemplate.unified_job_type') === 'job_template' || + _.get($scope, 'node.unifiedJobTemplate.type') === 'job_template' + ) { + let promises = [jobTemplate.optionsLaunch($scope.node.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.node.unifiedJobTemplate.id)]; + + if (_.has($scope, 'node.originalNodeObj.related.credentials')) { + Rest.setUrl($scope.node.originalNodeObj.related.credentials); + promises.push(Rest.get()); + } + + $q.all(promises) + .then((responses) => { + let launchOptions = responses[0].data, + launchConf = responses[1].data, + workflowNodeCredentials = responses[2] ? responses[2].data.results : []; + + let prompts = PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data, + currentValues: $scope.node.originalNodeObj + }); + + let defaultCredsWithoutOverrides = []; + + prompts.credentials.previousOverrides = _.cloneDeep(workflowNodeCredentials); + + const credentialHasScheduleOverride = (templateDefaultCred) => { + let credentialHasOverride = false; + workflowNodeCredentials.forEach((scheduleCred) => { + if (templateDefaultCred.credential_type === scheduleCred.credential_type) { + if ( + (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || + (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) + ) { + credentialHasOverride = true; + } + } + }); + + return credentialHasOverride; + }; + + if (_.has(launchConf, 'defaults.credentials')) { + launchConf.defaults.credentials.forEach((defaultCred) => { + if (!credentialHasScheduleOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + }); + } + + prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); + + if ((!$scope.node.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.node.unifiedJobTemplate.project) { + $scope.selectedTemplateInvalid = true; + } else { + $scope.selectedTemplateInvalid = false; + } + + let credentialRequiresPassword = false; + + prompts.credentials.value.forEach((credential) => { + if(credential.inputs) { + if ((credential.inputs.password && credential.inputs.password === "ASK") || + (credential.inputs.become_password && credential.inputs.become_password === "ASK") || + (credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") || + (credential.inputs.vault_password && credential.inputs.vault_password === "ASK") + ) { + credentialRequiresPassword = true; + } + } else if (credential.passwords_needed && credential.passwords_needed.length > 0) { + credentialRequiresPassword = true; + } + }); + + $scope.credentialRequiresPassword = credentialRequiresPassword; + + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; + $scope.nodeFormDataLoaded = true; + } else { + $scope.showPromptButton = true; + + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'node.originalNodeObj.summary_fields.inventory')) { + $scope.promptModalMissingReqFields = true; + } else { + $scope.promptModalMissingReqFields = false; + } + + if (responses[1].data.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions($scope.node.unifiedJobTemplate.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + extra_data: _.cloneDeep($scope.node.originalNodeObj.extra_data) + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); + + $scope.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + template: $scope.node.unifiedJobTemplate.id + }; + + surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + checkCredentialsForRequiredPasswords(); + + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + }); + } else { + $scope.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + template: $scope.node.unifiedJobTemplate.id + }; + + checkCredentialsForRequiredPasswords(); + + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + } + } + }); + } else { + $scope.nodeFormDataLoaded = true; + } + + if (_.get($scope, 'node.unifiedJobTemplate')) { + if (_.get($scope, 'node.unifiedJobTemplate.type') === "job_template") { + $scope.activeTab = "jobs"; + } + + $scope.selectedTemplate = $scope.node.unifiedJobTemplate; + + if ($scope.selectedTemplate.unified_job_type) { + switch ($scope.selectedTemplate.unified_job_type) { + case "job": + $scope.activeTab = "jobs"; + break; + case "project_update": + $scope.activeTab = "project_syncs"; + break; + case "inventory_update": + $scope.activeTab = "inventory_syncs"; + break; + } + } else if ($scope.selectedTemplate.type) { + switch ($scope.selectedTemplate.type) { + case "job_template": + $scope.activeTab = "jobs"; + break; + case "project": + $scope.activeTab = "project_syncs"; + break; + case "inventory_source": + $scope.activeTab = "inventory_syncs"; + break; + } + } + } else { + $scope.activeTab = "jobs"; + } + + if ($scope.mode === 'add') { + const alwaysOption = { + label: $scope.strings.get('workflow_maker.ALWAYS'), + value: 'always' + }; + const successOption = { + label: $scope.strings.get('workflow_maker.ON_SUCCESS'), + value: 'success' + }; + const failureOption = { + label: $scope.strings.get('workflow_maker.ON_FAILURE'), + value: 'failure' + }; + $scope.edgeTypeOptions = [alwaysOption]; + switch($scope.node.isRoot) { + case true: + $scope.edgeType = alwaysOption; + break; + case false: + $scope.edgeType = successOption; + $scope.edgeTypeOptions.push(successOption, failureOption); + break; + } + CreateSelect2({ + element: '#workflow_node_edge_3', + multiple: false + }); + + $scope.nodeFormDataLoaded = true; + } + }; + // Determine whether or not we need to go out and GET this nodes unified job template + // in order to determine whether or not prompt fields are needed + + $scope.openPromptModal = function() { + $scope.promptData.triggerModalOpen = true; + }; + + $scope.toggle_row = function(selectedRow) { + if (!$scope.readOnly) { + // TODO: make this more concise + switch($scope.activeTab) { + case 'jobs': + $scope.templates.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.templates[i].checked = 1; + + } else { + $scope.templates[i].checked = 0; + } + }); + break; + case 'project_syncs': + $scope.projects.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.projects[i].checked = 1; + } else { + $scope.projects[i].checked = 0; + } + }); + break; + case 'inventory_syncs': + $scope.inventory_sources.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.inventory_sources[i].checked = 1; + } else { + $scope.inventory_sources[i].checked = 0; + } + }); + break; + } + templateManuallySelected(selectedRow); + } + }; + + const templateManuallySelected = (selectedTemplate) => { + + if (promptWatcher) { + promptWatcher(); + } + + if (surveyQuestionWatcher) { + surveyQuestionWatcher(); + } + + if (credentialsWatcher) { + credentialsWatcher(); + } + + $scope.promptData = null; + + if (selectedTemplate.type === "job_template") { + let jobTemplate = new JobTemplate(); + + $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) + .then((responses) => { + let launchConf = responses[1].data; + + if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { + $scope.selectedTemplateInvalid = true; + } else { + $scope.selectedTemplateInvalid = false; + } + + if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { + $scope.credentialRequiresPassword = true; + } else { + $scope.credentialRequiresPassword = false; + } + + $scope.selectedTemplate = angular.copy(selectedTemplate); + + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; + } else { + $scope.showPromptButton = true; + + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { + $scope.promptModalMissingReqFields = true; + } else { + $scope.promptModalMissingReqFields = false; + } + + if (launchConf.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions(selectedTemplate.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + surveyQuestions: processed.surveyQuestions, + template: selectedTemplate.id, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } else { + $scope.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + template: selectedTemplate.id, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + watchForPromptChanges(); + } + } + }); + } else { + $scope.selectedTemplate = angular.copy(selectedTemplate); + $scope.selectedTemplateInvalid = false; + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; + } + }; + + const setupNodeForm = () => { + $scope.nodeFormDataLoaded = false; + $scope.template_queryset = { + page_size: '5', + order_by: 'name', + type: 'workflow_job_template,job_template' + }; + + $scope.templates = []; + $scope.template_dataset = {}; + + // Go out and GET the list contents for each of the tabs + + listPromises.push( + qs.search(GetBasePath('unified_job_templates'), $scope.template_queryset) + .then(function(res) { + $scope.template_dataset = res.data; + $scope.templates = $scope.template_dataset.results; + }) + ); + + $scope.project_queryset = { + page_size: '5', + order_by: 'name' + }; + + $scope.projects = []; + $scope.project_dataset = {}; + + listPromises.push( + qs.search(GetBasePath('projects'), $scope.project_queryset) + .then(function(res) { + $scope.project_dataset = res.data; + $scope.projects = $scope.project_dataset.results; + }) + ); + + $scope.inventory_source_dataset = { + page_size: '5', + order_by: 'name', + not__source: '' + } + + $scope.inventory_sources = []; + $scope.inventory_source_dataset = {}; + + listPromises.push( + qs.search(GetBasePath('inventory_sources'), $scope.inventory_source_dataset) + .then(function(res) { + $scope.inventory_source_dataset = res.data; + $scope.inventory_sources = $scope.inventory_source_dataset.results; + }) + ); + + $q.all(listPromises) + .then(() => { + if (!$scope.node.isNew && !$scope.node.edited && $scope.node.unifiedJobTemplate && $scope.node.unifiedJobTemplate.unified_job_type && $scope.node.unifiedJobTemplate.unified_job_type === 'job') { + // This is a node that we got back from the api with an incomplete + // unified job template so we're going to pull down the whole object + + TemplatesService.getUnifiedJobTemplate($scope.node.unifiedJobTemplate.id) + .then(function(data) { + $scope.node.unifiedJobTemplate = _.clone(data.data.results[0]); + finishConfiguringEdit(); + }, function(error) { + ProcessErrors($scope, error.data, error.status, null, { + hdr: 'Error!', + msg: 'Failed to get unified job template. GET returned ' + + 'status: ' + error.status + }); + }); + } else { + finishConfiguringEdit(); + } + }); + } + + setupNodeForm(); } ]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js index 197b6ae86b..2a273a4e0f 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js @@ -9,13 +9,16 @@ import workflowNodeFormController from './workflow-node-form.controller'; export default ['templateUrl', function(templateUrl) { return { - scope: {}, + scope: { + mode: '<', + node: '=', + cancel: '&', + select: '&', + readOnly: '<' + }, restrict: 'E', templateUrl: templateUrl('templates/workflows/workflow-maker/forms/workflow-node-form'), - controller: workflowNodeFormController, - link: function(scope) { - console.log('inside link function for workflow node form'); - } + controller: workflowNodeFormController }; } ]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index c35a26575a..c6c239f405 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -1,17 +1,111 @@ -
-
{{strings.get('workflow_maker.JOBS')}}
-
{{strings.get('workflow_maker.PROJECT_SYNC')}}
-
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
-
-
-
-
-
-
- +
+
{{mode === 'edit' ? node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
+
+
{{strings.get('workflow_maker.JOBS')}}
+
{{strings.get('workflow_maker.PROJECT_SYNC')}}
+
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
+
+
+
+
+ + +
+ +
+
No records matched your search.
+
+
PLEASE ADD ITEMS TO THIS LIST
+
+ + + + + + + + + + + + + + + +
+ +
+ + + {{ template.name }}
+
+ +
+
+
+ + +
+ +
+
No records matched your search.
+
+
No Projects Have Been Created
+
+ + + + + + + + + + + + + +
+
+ + + {{ project.name }}
+
+ +
+
+
+ + +
+ +
+
No records matched your search.
+
+
PLEASE ADD ITEMS TO THIS LIST
+
+ + + + + + + + + + + + + +
+
+ + + {{ inventory_source.name }}
+
+ +
+
@@ -24,28 +118,29 @@ {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}
-
-
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index 580d1aa231..3dd307a426 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -13,22 +13,26 @@ display: flex; height: 34px; } + .WorkflowMaker-title { align-items: center; flex: 1 0 auto; display: flex; height: 34px; } + .WorkflowMaker-titleText { color: @list-title-txt; font-size: 14px; font-weight: bold; margin-right: 10px; } + .WorkflowMaker-exitHolder { justify-content: flex-end; display: flex; } + .WorkflowMaker-exit{ cursor:pointer; padding:0px; @@ -40,9 +44,11 @@ transition: color 0.2s; line-height:1; } + .WorkflowMaker-exit:hover{ color:@default-icon; } + .WorkflowMaker-contentHolder { display: flex; border: 1px solid @b7grey; @@ -50,11 +56,13 @@ height: ~"calc(100% - 85px)"; overflow: hidden; } + .WorkflowMaker-contentLeft { flex: 1; flex-direction: column; height: 100%; } + .WorkflowMaker-contentRight { flex: 0 0 400px; border-left: 1px solid @b7grey; @@ -63,12 +71,14 @@ height: 100%; overflow-y: scroll; } + .WorkflowMaker-buttonHolder { height: 30px; display: flex; justify-content: flex-end; margin-top: 20px; } + .WorkflowMaker-saveButton{ background-color: @submit-button-bg; color: @submit-button-text; @@ -117,45 +127,55 @@ justify-content: center; border-radius: 4px; } + .WorkflowMaker-deleteModal { height: 200px; width: 600px; background-color: @default-bg; border-radius: 5px; } + .WorkflowMaker-formTitle { color: @list-title-txt; font-size: 14px; font-weight: bold; margin-bottom: 20px; } + .WorkflowMaker-formHelp { color: @default-interface-txt; } + .WorkflowMaker-formLists { margin-bottom: 20px; .SmartSearch-searchTermContainer { width: 100%; } } + .WorkflowMaker-formTitle { display: flex; color: @default-interface-txt; margin-right: 10px; } + .WorkflowMaker-formLabel { font-weight: normal; } + .WorkflowMaker-formElement { margin-bottom: 10px; } + .WorkflowMaker-chart { display: flex; width: 100%; } + .WorkflowMaker-totalJobs { margin-right: 5px; } + .WorkflowLegend-maker { display: flex; height: 40px; @@ -164,33 +184,39 @@ background: @default-bg; border-bottom: 1px solid @b7grey; } + .WorkflowLegend-maker--left { flex: 1 0 auto; } + .WorkflowLegend-maker--right { flex: 0 0 217px; text-align: right; padding-right: 20px; position: relative; } + .WorkflowLegend-onSuccessLegend { height: 4px; width: 20px; background-color: @submit-button-bg; margin: 18px 5px 18px 0px; } + .WorkflowLegend-onFailLegend { height: 4px; width: 20px; background-color: @default-err; margin: 18px 5px 18px 0px; } + .WorkflowLegend-alwaysLegend { height: 4px; width: 20px; background-color: @default-link; margin: 18px 5px 18px 0px; } + .WorkflowLegend-letterCircle{ border-radius: 50%; width: 20px; @@ -201,6 +227,7 @@ margin: 10px 5px 10px 0px; line-height: 20px; } + .WorkflowLegend-details { align-items: center; display: flex; @@ -215,6 +242,7 @@ display: block; flex: 1 0 auto; } + .WorkflowLegend-details--right { flex: 0 0 44px; text-align: right; @@ -229,15 +257,18 @@ font-size: 1.2em; margin-left: 15px; } + .Key-menuIcon:hover, .WorkflowMaker-manualControlsIcon:hover { color: @default-link-hov; cursor: pointer; } + .Key-menuIcon--active, .WorkflowMaker-manualControlsIcon--active { color: @default-link-hov; } + .WorkflowMaker-manualControls { position: absolute; left: -106px; @@ -251,6 +282,7 @@ margin-left: -1px; border-right: 0; } + .WorkflowLegend-manualControls { position: absolute; left: -272px; @@ -262,13 +294,16 @@ border: 1px solid @d7grey; border-bottom-left-radius: 5px; } + .WorkflowMaker-formTab { margin-right: 10px; } + .WorkflowMaker-preventBodyScrolling { height: 100%; overflow: hidden; } + .WorkflowMaker-invalidJobTemplateWarning { margin-bottom: 5px; color: @default-err; @@ -281,15 +316,18 @@ background-color: @default-bg; border: 1px solid @default-list-header-bg; } + .Key-listItem { display: flex; padding: 0; margin: 5px 0 0 0; } + .Key-listItemContent { margin: 0; line-height: 20px; } + .Key-listItemContent--circle { line-height: 28px; } @@ -300,27 +338,34 @@ margin: 9px 5px 9px 0px; outline: none; } + .Key-heading { font-weight: 700; margin: 0 0 10px; line-height: 0; padding: 0; } + .Key-icon--success { background-color: @submit-button-bg; } + .Key-icon--fail { background-color: @default-err; } + .Key-icon--always { background-color: @default-link; } + .Key-icon--warning { background: @default-warning; } + .Key-icon--default { background: @default-icon; } + .Key-icon--circle { border-radius: 50%; width: 20px; @@ -330,6 +375,7 @@ line-height: 20px; margin: 4px 5px 5px 0px; } + .Key-details { display: flex; height: 40px; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 64b3ef3334..10a697f213 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -11,27 +11,11 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate, Empty, PromptService, Rest, TemplatesStrings, $timeout, $state) { - let promptWatcher, surveyQuestionWatcher, credentialsWatcher; - $scope.strings = TemplatesStrings; + // TODO: I don't think this needs to be on scope but changing it will require changes to + // all the prompt places $scope.preventCredsWithPasswords = true; - $scope.workflowMakerFormConfig = { - nodeMode: "idle", - activeTab: "jobs", - formIsValid: false - }; - - $scope.job_type_options = [{ - label: $scope.strings.get('workflow_maker.RUN'), - value: "run" - }, { - label: $scope.strings.get('workflow_maker.CHECK'), - value: "check" - }]; - - $scope.edgeTypeOptions = createEdgeTypeOptions(); - let editRequests = []; let associateRequests = []; let disassociateRequests = []; @@ -41,32 +25,10 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.toggleKey = () => $scope.showKey = !$scope.showKey; $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; - function createEdgeTypeOptions() { - return ([{ - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: 'always' - }, - { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: 'success' - }, - { - label: $scope.strings.get('workflow_maker.ON_FAILURE'), - value: 'failure' - } - ]); - } - - function resetNodeForm() { - $scope.workflowMakerFormConfig.nodeMode = "idle"; - delete $scope.selectedTemplate; - delete $scope.placeholderNode; - delete $scope.betweenTwoNodes; - $scope.nodeBeingEdited = null; - $scope.workflowMakerFormConfig.activeTab = "jobs"; - - $scope.$broadcast('clearWorkflowLists'); - } + $scope.formState = { + 'showNodeForm': false, + 'showLinkForm': false + }; function recursiveNodeUpdates(params, completionCallback) { // params.parentId @@ -301,62 +263,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } } - let updateEdgeDropdownOptions = (edgeTypeValue) => { - // Not passing an edgeTypeValue will include all by default - - if (edgeTypeValue) { - $scope.edgeTypeOptions = _.filter(createEdgeTypeOptions(), { - 'value': edgeTypeValue - }); - } else { - $scope.edgeTypeOptions = createEdgeTypeOptions(); - } - - CreateSelect2({ - element: '#workflow_node_edge', - multiple: false - }); - }; - - let checkCredentialsForRequiredPasswords = () => { - let credentialRequiresPassword = false; - $scope.promptData.prompts.credentials.value.forEach((credential) => { - if ((credential.passwords_needed && - credential.passwords_needed.length > 0) || - (_.has(credential, 'inputs.vault_password') && - credential.inputs.vault_password === "ASK") - ) { - credentialRequiresPassword = true; - } - }); - $scope.credentialRequiresPassword = credentialRequiresPassword; - }; - - let watchForPromptChanges = () => { - let promptDataToWatch = [ - 'promptData.prompts.inventory.value', - 'promptData.prompts.verbosity.value', - 'missingSurveyValue' - ]; - - promptWatcher = $scope.$watchGroup(promptDataToWatch, function () { - let missingPromptValue = false; - if ($scope.missingSurveyValue) { - missingPromptValue = true; - } else if ($scope.selectedTemplate.type === 'job_template' && (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id)) { - missingPromptValue = true; - } - $scope.promptModalMissingReqFields = missingPromptValue; - }); - - if ($scope.promptData.launchConf.ask_credential_on_launch && $scope.credentialRequiresPassword) { - credentialsWatcher = $scope.$watch('promptData.prompts.credentials', () => { - checkCredentialsForRequiredPasswords(); - }); - } - }; - - $scope.closeWorkflowMaker = function () { + $scope.closeWorkflowMaker = function() { // Revert the data to the master which was created when the dialog was opened $scope.treeData.data = angular.copy($scope.treeDataMaster); $scope.closeDialog(); @@ -442,19 +349,15 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.startAddNode = function (parent, betweenTwoNodes) { - if ($scope.placeholderNode || $scope.nodeBeingEdited) { + if ($scope.nodeBeingWorkedOn) { $scope.cancelNodeForm(); } - if ($scope.linkBeingEdited) { + if ($scope.linkBeingWorkedOn) { $scope.cancelLinkForm(); } - $scope.workflowMakerFormConfig.nodeMode = "add"; - $scope.addParent = parent; - $scope.betweenTwoNodes = betweenTwoNodes; - - $scope.placeholderNode = WorkflowService.addPlaceholderNode({ + $scope.nodeBeingWorkedOn = WorkflowService.addPlaceholderNode({ parent: parent, betweenTwoNodes: betweenTwoNodes, tree: $scope.treeData.data, @@ -463,443 +366,96 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.treeData.nextIndex++; - // Set the default to success - let edgeType = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: "success" - }; - - if (parent && ((betweenTwoNodes && parent.source.isStartNode) || (!betweenTwoNodes && parent.isStartNode))) { - // This node will always be executed - updateEdgeDropdownOptions('always'); - edgeType = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: "always" - }; - } else { - updateEdgeDropdownOptions(); - } - - $scope.edgeType = edgeType; $scope.$broadcast("refreshWorkflowChart"); + $scope.nodeFormMode = "add"; + $scope.formState.showNodeForm = true; }; - $scope.confirmNodeForm = function () { - if ($scope.workflowMakerFormConfig.nodeMode === "add") { - if ($scope.selectedTemplate && $scope.edgeType && $scope.edgeType.value) { - - $scope.placeholderNode.unifiedJobTemplate = $scope.selectedTemplate; - $scope.placeholderNode.edgeType = $scope.edgeType.value; - if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template' || - $scope.placeholderNode.unifiedJobTemplate.type === 'workflow_job_template') { - $scope.placeholderNode.promptData = _.cloneDeep($scope.promptData); + $scope.confirmNodeForm = function(selectedTemplate, promptData, edgeType) { + if ($scope.nodeFormMode === "add") { + if (selectedTemplate && edgeType && edgeType.value) { + $scope.nodeBeingWorkedOn.unifiedJobTemplate = selectedTemplate; + $scope.nodeBeingWorkedOn.edgeType = edgeType.value; + if ($scope.nodeBeingWorkedOn.unifiedJobTemplate.type === 'job_template') { + $scope.nodeBeingWorkedOn.promptData = _.cloneDeep(promptData); } - $scope.placeholderNode.canEdit = true; + $scope.nodeBeingWorkedOn.canEdit = true; - delete $scope.placeholderNode.placeholder; - - resetNodeForm(); + delete $scope.nodeBeingWorkedOn.placeholder; // Increment the total node counter $scope.treeData.data.totalNodes++; } - } else if ($scope.workflowMakerFormConfig.nodeMode === "edit") { - if ($scope.selectedTemplate && $scope.edgeType && $scope.edgeType.value) { - $scope.nodeBeingEdited.unifiedJobTemplate = $scope.selectedTemplate; - $scope.nodeBeingEdited.edgeType = $scope.edgeType.value; - if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' || $scope.nodeBeingEdited.unifiedJobTemplate.type === 'workflow_job_template') { - $scope.nodeBeingEdited.promptData = _.cloneDeep($scope.promptData); + } else if ($scope.nodeFormMode === "edit") { + if (selectedTemplate) { + $scope.nodeBeingWorkedOn.unifiedJobTemplate = selectedTemplate; + + if ($scope.nodeBeingWorkedOn.unifiedJobTemplate.type === 'job_template') { + $scope.nodeBeingWorkedOn.promptData = _.cloneDeep(promptData); } - $scope.nodeBeingEdited.isActiveEdit = false; + $scope.nodeBeingWorkedOn.isActiveEdit = false; - $scope.nodeBeingEdited.edited = true; - - resetNodeForm(); + $scope.nodeBeingWorkedOn.edited = true; } } - if (promptWatcher) { - promptWatcher(); - } - - if (surveyQuestionWatcher) { - surveyQuestionWatcher(); - } - - if (credentialsWatcher) { - credentialsWatcher(); - } - - $scope.promptData = null; + $scope.formState.showNodeForm = false; + $scope.nodeFormMode = null; + $scope.nodeBeingWorkedOn = null; $scope.$broadcast("refreshWorkflowChart"); }; - $scope.cancelNodeForm = function () { - if ($scope.workflowMakerFormConfig.nodeMode === "add") { + $scope.cancelNodeForm = function() { + if ($scope.nodeFormMode === "add") { // Remove the placeholder node from the tree WorkflowService.removeNodeFromTree({ tree: $scope.treeData.data, - nodeToBeDeleted: $scope.placeholderNode + nodeToBeDeleted: $scope.nodeBeingWorkedOn }); - } else if ($scope.workflowMakerFormConfig.nodeMode === "edit") { - $scope.nodeBeingEdited.isActiveEdit = false; + } else if ($scope.nodeFormMode === "edit") { + $scope.nodeBeingWorkedOn.isActiveEdit = false; } - - if (promptWatcher) { - promptWatcher(); - } - - if (surveyQuestionWatcher) { - surveyQuestionWatcher(); - } - - if (credentialsWatcher) { - credentialsWatcher(); - } - - $scope.promptData = null; - $scope.selectedTemplateInvalid = false; - $scope.showPromptButton = false; - - // Reset the form - resetNodeForm(); - + $scope.formState.showNodeForm = false; + $scope.nodeBeingWorkedOn = null; + $scope.nodeFormMode = null; $scope.$broadcast("refreshWorkflowChart"); }; /* EDIT NODE FUNCTIONS */ - $scope.startEditNode = function (nodeToEdit) { - $scope.editNodeHelpMessage = null; - - if ($scope.linkBeingEdited) { + $scope.startEditNode = function(nodeToEdit) { + if ($scope.linkBeingWorkedOn) { $scope.cancelLinkForm(); } - if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { - if ($scope.placeholderNode || $scope.nodeBeingEdited) { + if (!$scope.nodeBeingWorkedOn || ($scope.nodeBeingWorkedOn && $scope.nodeBeingWorkedOn.id !== nodeToEdit.id)) { + if ($scope.nodeBeingWorkedOn) { $scope.cancelNodeForm(); - - // Refresh this object as the parent has changed - nodeToEdit = WorkflowService.searchTree({ - element: $scope.treeData.data, - matchingId: nodeToEdit.id - }); } - $scope.workflowMakerFormConfig.nodeMode = "edit"; + $scope.nodeFormMode = "edit"; + + $scope.formState.showNodeForm = true; let parent = WorkflowService.searchTree({ element: $scope.treeData.data, matchingId: nodeToEdit.parent.id }); - $scope.nodeBeingEdited = WorkflowService.searchTree({ + $scope.nodeBeingWorkedOn = WorkflowService.searchTree({ element: parent, matchingId: nodeToEdit.id }); - $scope.nodeBeingEdited.isActiveEdit = true; - - let finishConfiguringEdit = function () { - let templateType = $scope.nodeBeingEdited.unifiedJobTemplate.type; - let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); - if (!_.isEmpty($scope.nodeBeingEdited.promptData)) { - $scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData); - const launchConf = $scope.promptData.launchConf; - - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.credential_needed_to_start && - !launchConf.ask_variables_on_launch && - launchConf.variables_needed_to_start.length === 0) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } else { - $scope.showPromptButton = true; - - if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; - } else { - $scope.promptModalMissingReqFields = false; - } - } - } else if ( - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job' || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'workflow_job_template' - ) { - let promises = [jobTemplate.optionsLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id)]; - - if (_.has($scope, 'nodeBeingEdited.originalNodeObj.related.credentials')) { - Rest.setUrl($scope.nodeBeingEdited.originalNodeObj.related.credentials); - promises.push(Rest.get()); - } - - $q.all(promises) - .then((responses) => { - let launchOptions = responses[0].data, - launchConf = responses[1].data, - workflowNodeCredentials = responses[2] ? responses[2].data.results : []; - - let prompts = PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data, - currentValues: $scope.nodeBeingEdited.originalNodeObj - }); - - let defaultCredsWithoutOverrides = []; - - prompts.credentials.previousOverrides = _.cloneDeep(workflowNodeCredentials); - - const credentialHasScheduleOverride = (templateDefaultCred) => { - let credentialHasOverride = false; - workflowNodeCredentials.forEach((scheduleCred) => { - if (templateDefaultCred.credential_type === scheduleCred.credential_type) { - if ( - (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || - (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) - ) { - credentialHasOverride = true; - } - } - }); - - return credentialHasOverride; - }; - - if (_.has(launchConf, 'defaults.credentials')) { - launchConf.defaults.credentials.forEach((defaultCred) => { - if (!credentialHasScheduleOverride(defaultCred)) { - defaultCredsWithoutOverrides.push(defaultCred); - } - }); - } - - prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); - - if ($scope.nodeBeingEdited.unifiedJobTemplate.unified_job_template === 'job') { - if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) { - $scope.selectedTemplateInvalid = true; - } else { - $scope.selectedTemplateInvalid = false; - } - } else { - $scope.selectedTemplateInvalid = false; - } - - let credentialRequiresPassword = false; - - prompts.credentials.value.forEach((credential) => { - if (credential.inputs) { - if ((credential.inputs.password && credential.inputs.password === "ASK") || - (credential.inputs.become_password && credential.inputs.become_password === "ASK") || - (credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") || - (credential.inputs.vault_password && credential.inputs.vault_password === "ASK") - ) { - credentialRequiresPassword = true; - } - } else if (credential.passwords_needed && credential.passwords_needed.length > 0) { - credentialRequiresPassword = true; - } - }); - - $scope.credentialRequiresPassword = credentialRequiresPassword; - - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.credential_needed_to_start && - !launchConf.ask_variables_on_launch && - launchConf.variables_needed_to_start.length === 0) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } else { - $scope.showPromptButton = true; - - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; - } else { - $scope.promptModalMissingReqFields = false; - } - - if (responses[1].data.survey_enabled) { - // go out and get the survey questions - jobTemplate.getSurveyQuestions($scope.nodeBeingEdited.unifiedJobTemplate.id) - .then((surveyQuestionRes) => { - - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec, - extra_data: _.cloneDeep($scope.nodeBeingEdited.originalNodeObj.extra_data) - }); - - $scope.missingSurveyValue = processed.missingSurveyValue; - - $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); - - $scope.nodeBeingEdited.promptData = $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - surveyQuestions: surveyQuestionRes.data.spec, - templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type, - template: $scope.nodeBeingEdited.unifiedJobTemplate.id - }; - - surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if (question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } - }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - - checkCredentialsForRequiredPasswords(); - - watchForPromptChanges(); - }); - } else { - $scope.nodeBeingEdited.promptData = $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - templateType: $scope.nodeBeingEdited.unifiedJobTemplate.type, - template: $scope.nodeBeingEdited.unifiedJobTemplate.id - }; - - checkCredentialsForRequiredPasswords(); - - watchForPromptChanges(); - } - } - }); - } - - if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate')) { - - if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "job_template" || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "workflow_job_template") { - $scope.workflowMakerFormConfig.activeTab = "jobs"; - } - - $scope.selectedTemplate = $scope.nodeBeingEdited.unifiedJobTemplate; - - if ($scope.selectedTemplate.unified_job_type) { - switch ($scope.selectedTemplate.unified_job_type) { - case "job": - case "workflow_job": - $scope.workflowMakerFormConfig.activeTab = "jobs"; - break; - case "project_update": - $scope.workflowMakerFormConfig.activeTab = "project_sync"; - break; - case "inventory_update": - $scope.workflowMakerFormConfig.activeTab = "inventory_sync"; - break; - } - } else if ($scope.selectedTemplate.type) { - switch ($scope.selectedTemplate.type) { - case "job_template": - case "workflow_job_template": - $scope.workflowMakerFormConfig.activeTab = "jobs"; - break; - case "project": - $scope.workflowMakerFormConfig.activeTab = "project_sync"; - break; - case "inventory_source": - $scope.workflowMakerFormConfig.activeTab = "inventory_sync"; - break; - } - } - } else { - $scope.workflowMakerFormConfig.activeTab = "jobs"; - } - - let edgeDropdownOptions = null; - - // Select RUN dropdown option - switch ($scope.nodeBeingEdited.edgeType) { - case "always": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: "always" - }; - if ($scope.nodeBeingEdited.isRoot) { - edgeDropdownOptions = 'always'; - } - break; - case "success": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: "success" - }; - break; - case "failure": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ON_FAILURE'), - value: "failure" - }; - break; - } - - $timeout(updateEdgeDropdownOptions(edgeDropdownOptions)); - - $scope.$broadcast("refreshWorkflowChart"); - }; - - // Determine whether or not we need to go out and GET this nodes unified job template - // in order to determine whether or not prompt fields are needed - if (!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited && - (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job')) { - // This is a node that we got back from the api with an incomplete - // unified job template so we're going to pull down the whole object - - TemplatesService.getUnifiedJobTemplate($scope.nodeBeingEdited.unifiedJobTemplate.id) - .then(function (data) { - $scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]); - finishConfiguringEdit(); - }, function ({ - data, - status, - config - }) { - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER'), - msg: $scope.strings.get('error.CALL', { - path: `${config.url}`, - action: `${config.method}`, - status - }) - }); - }); - } else { - finishConfiguringEdit(); - } - + $scope.nodeBeingWorkedOn.isActiveEdit = true; } - }; + $scope.$broadcast("refreshWorkflowChart"); + } /* EDIT LINK FUNCTIONS */ @@ -907,18 +463,17 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', const setupLinkEdit = () => { const parentNode = WorkflowService.searchTree({ element: $scope.treeData.data, - matchingId: parentId, - byNodeId: true + matchingId: parentId }); parentNode.isLinkEditParent = true; // Loop across children looking for childId - const childNode = _.find(parentNode.children, {'nodeId': childId}); + const childNode = _.find(parentNode.children, {'id': childId}); childNode.isLinkEditChild = true; - $scope.linkBeingEdited = { + $scope.linkBeingWorkedOn = { parent: parentNode, child: childNode } @@ -934,19 +489,19 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }, edgeType: childNode.edgeType } - $scope.editLink = true; + $scope.formState.showLinkForm = true; $scope.$broadcast("refreshWorkflowChart"); } - if ($scope.nodeBeingEdited || $scope.placeholderNode) { + if ($scope.nodeBeingWorkedOn) { $scope.cancelNodeForm(); } - if ($scope.linkBeingEdited) { - if ($scope.linkBeingEdited.parent.nodeId !== parentId || $scope.linkBeingEdited.child.nodeId !== childId) { - $scope.linkBeingEdited.parent.isLinkEditParent = false; - $scope.linkBeingEdited.child.isLinkEditChild = false; + if ($scope.linkBeingWorkedOn) { + if ($scope.linkBeingWorkedOn.parent.nodeId !== parentId || $scope.linkBeingWorkedOn.child.nodeId !== childId) { + $scope.linkBeingWorkedOn.parent.isLinkEditParent = false; + $scope.linkBeingWorkedOn.child.isLinkEditChild = false; setupLinkEdit() } } else { @@ -955,34 +510,20 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }; - $scope.confirmLinkForm = (parentId, childId, edgeType) => { - $scope.linkBeingEdited.parent.isLinkEditParent = false; - $scope.linkBeingEdited.child.isLinkEditChild = false; - const parentNode = WorkflowService.searchTree({ - element: $scope.treeData.data, - matchingId: parentId, - byNodeId: true - }); - - // Loop across children looking for childId - const childNode = _.find(parentNode.children, {'nodeId': childId}); - - childNode.edgeType = edgeType; - - $scope.linkBeingEdited = null; - - $scope.editLink = false; - + $scope.confirmLinkForm = (newEdgeType) => { + $scope.linkBeingWorkedOn.parent.isLinkEditParent = false; + $scope.linkBeingWorkedOn.child.isLinkEditChild = false; + $scope.linkBeingWorkedOn.child.edgeType = newEdgeType; + $scope.linkBeingWorkedOn = null; + $scope.formState.showLinkForm = false; $scope.$broadcast("refreshWorkflowChart"); } $scope.cancelLinkForm = () => { - $scope.linkBeingEdited.parent.isLinkEditParent = false; - $scope.linkBeingEdited.child.isLinkEditChild = false; - $scope.linkBeingEdited = null; - - $scope.editLink = false; - + $scope.linkBeingWorkedOn.parent.isLinkEditParent = false; + $scope.linkBeingWorkedOn.child.isLinkEditChild = false; + $scope.linkBeingWorkedOn = null; + $scope.formState.showLinkForm = false; $scope.$broadcast("refreshWorkflowChart"); }; @@ -1005,7 +546,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.confirmDeleteNode = function () { if ($scope.nodeToBeDeleted) { - if ($scope.linkBeingEdited) { + if ($scope.linkBeingWorkedOn) { $scope.cancelLinkForm(); } @@ -1020,225 +561,16 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.treeData.data.deletedNodes.push($scope.nodeToBeDeleted.nodeId); } - if ($scope.nodeToBeDeleted.isActiveEdit) { - resetNodeForm(); - } - resetDeleteNode(); $scope.$broadcast("refreshWorkflowChart"); - if ($scope.placeholderNode) { - let edgeType = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: "success" - }; - - if ($scope.placeholderNode.isRoot) { - updateEdgeDropdownOptions('always'); - edgeType = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: "always" - }; - } else { - updateEdgeDropdownOptions(); - } - - $scope.edgeType = edgeType; - } else if ($scope.nodeBeingEdited) { - - switch ($scope.nodeBeingEdited.edgeType) { - case "always": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: "always" - }; - if ($scope.nodeBeingEdited.isRoot) { - updateEdgeDropdownOptions('always'); - } else { - updateEdgeDropdownOptions(); - } - break; - case "success": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: "success" - }; - updateEdgeDropdownOptions(); - break; - case "failure": - $scope.edgeType = { - label: $scope.strings.get('workflow_maker.ON_FAILURE'), - value: "failure" - }; - updateEdgeDropdownOptions(); - break; - } - } - $scope.treeData.data.totalNodes--; } }; - $scope.toggleFormTab = function (tab) { - if ($scope.workflowMakerFormConfig.activeTab !== tab) { - $scope.workflowMakerFormConfig.activeTab = tab; - } - }; - - function getEditNodeHelpMessage(workflowTemplate, selectedTemplate) { - if (selectedTemplate.type === "workflow_job_template") { - if (workflowTemplate.inventory) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); - } - } - - if (workflowTemplate.ask_inventory_on_launch) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); - } - } - } - - if (selectedTemplate.type === "job_template") { - if (workflowTemplate.inventory) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); - } - - return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); - } - - if (workflowTemplate.ask_inventory_on_launch) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); - } - - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); - } - } - - return null; - } - - $scope.templateManuallySelected = function (selectedTemplate) { - - if (promptWatcher) { - promptWatcher(); - } - - if (surveyQuestionWatcher) { - surveyQuestionWatcher(); - } - - if (credentialsWatcher) { - credentialsWatcher(); - } - - $scope.promptData = null; - $scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.treeData.workflow_job_template_obj, selectedTemplate); - - if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { - let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); - - $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) - .then((responses) => { - const launchConf = jobTemplate.getLaunchConf(); - - if (selectedTemplate.type === 'job_template') { - if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { - $scope.selectedTemplateInvalid = true; - } else { - $scope.selectedTemplateInvalid = false; - } - - if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { - $scope.credentialRequiresPassword = true; - } else { - $scope.credentialRequiresPassword = false; - } - } - - $scope.selectedTemplate = angular.copy(selectedTemplate); - - if (jobTemplate.canLaunchWithoutPrompt()) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } else { - $scope.showPromptButton = true; - - if (['job_template', 'workflow_job_template'].includes(selectedTemplate.type)) { - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { - $scope.promptModalMissingReqFields = true; - } else { - $scope.promptModalMissingReqFields = false; - } - } else { - $scope.promptModalMissingReqFields = false; - } - - if (launchConf.survey_enabled) { - // go out and get the survey questions - jobTemplate.getSurveyQuestions(selectedTemplate.id) - .then((surveyQuestionRes) => { - - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec - }); - - $scope.missingSurveyValue = processed.missingSurveyValue; - $scope.promptData = { - launchConf, - launchOptions: responses[0].data, - surveyQuestions: processed.surveyQuestions, - template: selectedTemplate.id, - templateType: selectedTemplate.type, - prompts: PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data - }), - }; - - surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if (question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } - }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - - watchForPromptChanges(); - }); - } else { - - $scope.promptData = { - launchConf, - launchOptions: responses[0].data, - template: selectedTemplate.id, - templateType: selectedTemplate.type, - prompts: PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data - }), - }; - - watchForPromptChanges(); - } - } - }); - } else { - $scope.selectedTemplate = angular.copy(selectedTemplate); - $scope.selectedTemplateInvalid = false; - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } - }; - - $scope.toggleManualControls = function () { + $scope.toggleManualControls = function() { $scope.showManualControls = !$scope.showManualControls; }; @@ -1268,10 +600,6 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.$broadcast('zoomToFitChart'); }; - $scope.openPromptModal = function () { - $scope.promptData.triggerModalOpen = true; - }; - let allNodes = []; let page = 1; @@ -1310,11 +638,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', // This is the last page buildTreeFromNodes(); } - }, function ({ - data, - status, - config - }) { + }, function ({ data, status, config }) { ProcessErrors($scope, data, status, null, { hdr: $scope.strings.get('error.HEADER'), msg: $scope.strings.get('error.CALL', { @@ -1327,7 +651,5 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', }; getNodes(); - - updateEdgeDropdownOptions(); } ]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index b7c18669e5..aaa1c6aef0 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -84,71 +84,16 @@
- -
{{(workflowMakerFormConfig.nodeMode === 'edit' && nodeBeingEdited) ? ((nodeBeingEdited.unifiedJobTemplate && nodeBeingEdited.unifiedJobTemplate.name) ? nodeBeingEdited.unifiedJobTemplate.name : strings.get('workflow_maker.EDIT_TEMPLATE')) : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
-
-
-
-
{{strings.get('workflow_maker.JOBS')}}
-
{{strings.get('workflow_maker.PROJECT_SYNC')}}
-
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
-
-
-
-
-
-
- -
-
- - {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} -
-
-
-
- - {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} -
-
-
- -
- -
-
-
- - - - -
-
-
+ + - - + +
- +
- diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less index 3fbe218d88..60141e7a3a 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.block.less +++ b/awx/ui/client/src/workflow-results/workflow-results.block.less @@ -157,3 +157,10 @@ border-radius: 5px; font-size: 11px; } + +.WorkflowResults-rightSide .WorkflowChart-svg { + background-color: @f6grey; + border: 1px solid @d7grey; + border-top: 0px; + border-bottom-right-radius: 5px; +} From 7b22d1b8749a6d3b656d23662c2571ac867f656f Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 25 Oct 2018 13:46:03 -0400 Subject: [PATCH 24/99] cycle detection when multiple parents --- awx/api/views/__init__.py | 38 ++++++++++------------------ awx/main/scheduler/dag_simple.py | 40 ++++++++++++++++++++++++++---- awx/main/scheduler/dag_workflow.py | 10 ++++++-- 3 files changed, 56 insertions(+), 32 deletions(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index fc0992c958..c25977db41 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -87,6 +87,7 @@ from awx.api.renderers import * # noqa from awx.api.serializers import * # noqa from awx.api.metadata import RoleMetadata, JobTypeMetadata from awx.main.constants import ACTIVE_STATES +from awx.main.scheduler.dag_workflow import WorkflowDAG from awx.api.views.mixin import ( ActivityStreamEnforcementMixin, SystemTrackingEnforcementMixin, @@ -143,6 +144,9 @@ from awx.api.views.root import ( # noqa ) +logger = logging.getLogger('awx.api.views') + + def api_exception_handler(exc, context): ''' Override default API exception handler to catch IntegrityError exceptions. @@ -2950,33 +2954,17 @@ class WorkflowJobTemplateNodeChildrenBaseList(WorkflowsEnforcementMixin, Enforce if created: return None - workflow_nodes = parent.workflow_job_template.workflow_job_template_nodes.all().\ - prefetch_related('success_nodes', 'failure_nodes', 'always_nodes') - graph = {} - for workflow_node in workflow_nodes: - graph[workflow_node.pk] = dict(node_object=workflow_node, metadata={'parent': None, 'traversed': False}) + if parent.id == sub.id: + return {"Error": _("Cycle detected.")} - find = False - for node_type in ['success_nodes', 'failure_nodes', 'always_nodes']: - for workflow_node in workflow_nodes: - parent_node = graph[workflow_node.pk] - related_nodes = getattr(parent_node['node_object'], node_type).all() - for related_node in related_nodes: - sub_node = graph[related_node.pk] - sub_node['metadata']['parent'] = parent_node - if not find and parent == workflow_node and sub == related_node and self.relationship == node_type: - find = True - if not find: - sub_node = graph[sub.pk] - parent_node = graph[parent.pk] - sub_node['metadata']['parent'] = parent_node - iter_node = sub_node - while iter_node is not None: - if iter_node['metadata']['traversed']: - return {"Error": _("Cycle detected.")} - iter_node['metadata']['traversed'] = True - iter_node = iter_node['metadata']['parent'] + parent_node_type_relationship = getattr(parent, self.relationship) + parent_node_type_relationship.add(sub) + parent.save() + graph = WorkflowDAG(parent.workflow_job_template) + if graph.has_cycle(): + parent_node_type_relationship.remove(sub) + return {"Error": _("Cycle detected.")} return None diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index c78e76482d..b82a7b808b 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -23,9 +23,9 @@ class SimpleDAG(object): def run_status(obj): dnr = "RUN" status = "NA" - if obj.job: + if hasattr(obj, 'job') and obj.job and hasattr(obj.job, 'status'): status = obj.job.status - if obj.do_not_run is True: + if hasattr(obj, 'do_not_run') and obj.do_not_run is True: dnr = "DNR" return "{}_{}_{}".format(dnr, status, obj.id) @@ -36,7 +36,7 @@ class SimpleDAG(object): for n in self.nodes: obj = n['node_object'] status = "NA" - if obj.job: + if hasattr(obj, 'job') and obj.job: status = obj.job.status color = 'black' if status == 'successful': @@ -65,8 +65,12 @@ class SimpleDAG(object): def add_edge(self, from_obj, to_obj, label=None): from_obj_ord = self.find_ord(from_obj) to_obj_ord = self.find_ord(to_obj) - if from_obj_ord is None or to_obj_ord is None: - raise LookupError("Object not found") + if from_obj_ord is None and to_obj_ord is None: + raise LookupError("From object {} and to object not found".format(from_obj, to_obj)) + elif from_obj_ord is None: + raise LookupError("From object not found {}".format(from_obj)) + elif to_obj_ord is None: + raise LookupError("To object not found {}".format(to_obj)) self.edges.append((from_obj_ord, to_obj_ord, label)) def add_edges(self, edgelist): @@ -116,3 +120,29 @@ class SimpleDAG(object): if len(self.get_dependents(n['node_object'])) < 1: roots.append(n) return roots + + def has_cycle(self): + node_objs = [node['node_object'] for node in self.get_root_nodes()] + nodes_visited = set([]) + path = set([]) + stack = node_objs + path_direction = 'DOWN' + + while stack: + node_obj = stack.pop() + + children = self.get_dependencies(node_obj) + for child in children: + if child['node_object'] not in nodes_visited: + stack.append(child['node_object']) + if node_obj in path: + return True + + if not children: + path_direction = 'UP' + + if path_direction == 'DOWN': + path.add(node_obj) + elif path_direction == 'UP': + path.discard(node_obj) + return False diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index f973fcf4d8..89cea454e3 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -12,8 +12,14 @@ class WorkflowDAG(SimpleDAG): if workflow_job: self._init_graph(workflow_job) - def _init_graph(self, workflow_job): - node_qs = workflow_job.workflow_job_nodes + def _init_graph(self, workflow_job_or_jt): + if hasattr(workflow_job_or_jt, 'workflow_job_template_nodes'): + node_qs = workflow_job_or_jt.workflow_job_template_nodes + elif hasattr(workflow_job_or_jt, 'workflow_job_nodes'): + node_qs = workflow_job_or_jt.workflow_job_nodes + else: + raise RuntimeError("Unexpected object {} {}".format(type(workflow_job_or_jt), workflow_job_or_jt)) + workflow_nodes = node_qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes').all() for workflow_node in workflow_nodes: self.add_node(workflow_node) From dfccc9e07db137a8a07c4170415b8b252c4d7bfd Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 31 Oct 2018 16:13:19 -0400 Subject: [PATCH 25/99] rework wf cycle detection for convergence --- ...e.py => 0051_v340_workflow_convergence.py} | 2 +- awx/main/scheduler/dag_simple.py | 24 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) rename awx/main/migrations/{0050_v340_workflow_convergence.py => 0051_v340_workflow_convergence.py} (85%) diff --git a/awx/main/migrations/0050_v340_workflow_convergence.py b/awx/main/migrations/0051_v340_workflow_convergence.py similarity index 85% rename from awx/main/migrations/0050_v340_workflow_convergence.py rename to awx/main/migrations/0051_v340_workflow_convergence.py index 2e6edd42d7..c874acb2e2 100644 --- a/awx/main/migrations/0050_v340_workflow_convergence.py +++ b/awx/main/migrations/0051_v340_workflow_convergence.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0049_v330_validate_instance_capacity_adjustment'), + ('main', '0050_v340_drop_celery_tables'), ] operations = [ diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index b82a7b808b..a8d96b4be6 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -123,26 +123,24 @@ class SimpleDAG(object): def has_cycle(self): node_objs = [node['node_object'] for node in self.get_root_nodes()] - nodes_visited = set([]) + node_objs_visited = set([]) path = set([]) stack = node_objs - path_direction = 'DOWN' while stack: node_obj = stack.pop() - children = self.get_dependencies(node_obj) - for child in children: - if child['node_object'] not in nodes_visited: - stack.append(child['node_object']) - if node_obj in path: - return True + children = [node['node_object'] for node in self.get_dependencies(node_obj)] + children_to_add = filter(lambda node_obj: node_obj not in node_objs_visited, children) - if not children: - path_direction = 'UP' - - if path_direction == 'DOWN': + if children_to_add: + if node_obj in path: + return True path.add(node_obj) - elif path_direction == 'UP': + stack.append(node_obj) + stack.extend(children_to_add) + else: + node_objs_visited.add(node_obj) path.discard(node_obj) + return False From 9afc38b71459aac71229a7a91eb50ca35d39742a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 31 Oct 2018 16:20:25 -0400 Subject: [PATCH 26/99] fixup migrations --- ...orkflow_convergence.py => 0052_v340_workflow_convergence.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0051_v340_workflow_convergence.py => 0052_v340_workflow_convergence.py} (89%) diff --git a/awx/main/migrations/0051_v340_workflow_convergence.py b/awx/main/migrations/0052_v340_workflow_convergence.py similarity index 89% rename from awx/main/migrations/0051_v340_workflow_convergence.py rename to awx/main/migrations/0052_v340_workflow_convergence.py index c874acb2e2..408c980706 100644 --- a/awx/main/migrations/0051_v340_workflow_convergence.py +++ b/awx/main/migrations/0052_v340_workflow_convergence.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0050_v340_drop_celery_tables'), + ('main', '0051_v340_job_slicing'), ] operations = [ From 2f9dc4d075d45b1c6765f1748ae97df4a00e70a9 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 1 Nov 2018 12:45:17 -0400 Subject: [PATCH 27/99] remove relationship in view if cycle detected --- awx/api/views/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index c25977db41..ac65ce9247 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -2959,12 +2959,12 @@ class WorkflowJobTemplateNodeChildrenBaseList(WorkflowsEnforcementMixin, Enforce parent_node_type_relationship = getattr(parent, self.relationship) parent_node_type_relationship.add(sub) - parent.save() graph = WorkflowDAG(parent.workflow_job_template) if graph.has_cycle(): parent_node_type_relationship.remove(sub) return {"Error": _("Cycle detected.")} + parent_node_type_relationship.remove(sub) return None From 6e40e9c8560867a131857aee5dd6a6b37800d413 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 1 Nov 2018 12:45:43 -0400 Subject: [PATCH 28/99] handle edge case ring cycle --- awx/main/scheduler/dag_simple.py | 3 +++ .../tests/functional/models/test_workflow.py | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index a8d96b4be6..2e4903b7a0 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -127,6 +127,9 @@ class SimpleDAG(object): path = set([]) stack = node_objs + if len(self.nodes) != 0 and len(node_objs) == 0: + return True + while stack: node_obj = stack.pop() diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index f406085aca..63253907a3 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -3,11 +3,17 @@ import pytest # AWX -from awx.main.models.workflow import WorkflowJob, WorkflowJobNode, WorkflowJobTemplateNode, WorkflowJobTemplate +from awx.main.models.workflow import ( + WorkflowJob, + WorkflowJobNode, + WorkflowJobTemplateNode, + WorkflowJobTemplate, +) from awx.main.models.jobs import JobTemplate, Job from awx.main.models.projects import ProjectUpdate from awx.main.scheduler.dag_workflow import WorkflowDAG from awx.api.versioning import reverse +from awx.api.views import WorkflowJobTemplateNodeSuccessNodesList # Django from django.test import TransactionTestCase @@ -237,16 +243,12 @@ class TestWorkflowJobTemplate: assert parent_qs[0] == wfjt.workflow_job_template_nodes.all()[1] def test_topology_validator(self, wfjt): - from awx.api.views import WorkflowJobTemplateNodeChildrenBaseList - test_view = WorkflowJobTemplateNodeChildrenBaseList() + test_view = WorkflowJobTemplateNodeSuccessNodesList() nodes = wfjt.workflow_job_template_nodes.all() - node_assoc = WorkflowJobTemplateNode.objects.create(workflow_job_template=wfjt) - nodes[2].always_nodes.add(node_assoc) # test cycle validation - assert test_view.is_valid_relation(node_assoc, nodes[0]) == {'Error': 'Cycle detected.'} - # test mutex validation - test_view.relationship = 'failure_nodes' - + print(nodes[0].success_nodes.get(id=nodes[1].id).failure_nodes.get(id=nodes[2].id)) + assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'} + def test_always_success_failure_creation(self, wfjt, admin, get): wfjt_node = wfjt.workflow_job_template_nodes.all()[1] node = WorkflowJobTemplateNode.objects.create(workflow_job_template=wfjt) From f8c53f4933e1a1195386302f2c210f06c36d8b58 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 1 Nov 2018 17:44:23 -0400 Subject: [PATCH 29/99] handle job error state in convergence --- awx/main/scheduler/dag_workflow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 89cea454e3..5e9abe8de9 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -50,7 +50,7 @@ class WorkflowDAG(SimpleDAG): return False # Node decidedly got a job; check if job is done - if p.job and p.job.status not in ['successful', 'failed']: + if p.job and p.job.status not in ['successful', 'failed', 'error']: return False return True @@ -69,7 +69,7 @@ class WorkflowDAG(SimpleDAG): continue if obj.job: - if obj.job.status == 'failed': + if obj.job.status in ['failed', 'error']: nodes.extend(self.get_dependencies(obj, 'failure_nodes') + self.get_dependencies(obj, 'always_nodes')) elif obj.job.status == 'successful': @@ -121,9 +121,9 @@ class WorkflowDAG(SimpleDAG): else: is_failed = True if children_all else job.status in ['failed', 'canceled', 'error'] - if job.status in ['canceled', 'error']: + if job.status == 'canceled': continue - elif job.status == 'failed': + elif job.status in ['error', 'failed']: nodes.extend(children_failed + children_always) elif job.status == 'successful': nodes.extend(children_success + children_always) @@ -168,7 +168,7 @@ class WorkflowDAG(SimpleDAG): if node in (self.get_dependencies(p, 'success_nodes') + self.get_dependencies(p, 'always_nodes')): return False - elif p.job.status == 'failed': + elif p.job.status in ['failed', 'error']: if node in (self.get_dependencies(p, 'failure_nodes') + self.get_dependencies(p, 'always_nodes')): return False @@ -203,7 +203,7 @@ class WorkflowDAG(SimpleDAG): self.get_dependencies(obj, 'failure_nodes') + self.get_dependencies(obj, 'always_nodes')) elif obj.job: - if obj.job.status == 'failed': + if obj.job.status in ['failed', 'error']: nodes.extend(self.get_dependencies(obj, 'success_nodes')) elif obj.job.status == 'successful': nodes.extend(self.get_dependencies(obj, 'failure_nodes')) From 584b3f4e3dcb95d6fff8d890659e58794e1d9d27 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 2 Nov 2018 14:29:37 -0400 Subject: [PATCH 30/99] remove workflow test * We now handle workflows with jobs that have errored. We treat them the same as a failure result. Before, we would abort the workflow when we encountered an error. --- awx/main/tests/functional/models/test_workflow.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index 63253907a3..81b86793f7 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -77,13 +77,6 @@ class TestWorkflowDAGFunctional(TransactionTestCase): self.assertTrue(is_done) self.assertTrue(has_failed) - def test_workflow_fails_for_unfinished_node(self): - wfj = self.workflow_job(states=['error', None, None, None, None]) - dag = WorkflowDAG(workflow_job=wfj) - is_done, has_failed = dag.is_workflow_done() - self.assertTrue(is_done) - self.assertTrue(has_failed) - def test_workflow_fails_for_no_error_handler(self): wfj = self.workflow_job(states=['successful', 'failed', None, None, None]) dag = WorkflowDAG(workflow_job=wfj) From 17b3996568e82a47762360817575673a7a693116 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 2 Nov 2018 14:36:44 -0400 Subject: [PATCH 31/99] fix flake8 anyway I can --- .../tests/functional/models/test_workflow.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index 81b86793f7..c4e07ac8c7 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -108,20 +108,17 @@ class TestWorkflowDNR(): Workflow topology: node[0] /\ - s/ \f + s f / \ node[1] node[3] / \ - s/ \f + s f / \ - node[2] node[4] - \ / - \ / - \ / - s f - \ / - \ / - node[5] + node[2] node[4] + \ / + s f + \ / + node[5] """ wfj = WorkflowJob.objects.create() jt = JobTemplate.objects.create(name='test-jt') From 1120f8b1e13b935a5dd3fee53dc5fadbbe158b4a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 2 Nov 2018 14:51:55 -0400 Subject: [PATCH 32/99] try2 at the devil flake8 --- .../tests/functional/models/test_workflow.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index c4e07ac8c7..d6f79b8ec8 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -107,18 +107,18 @@ class TestWorkflowDNR(): """ Workflow topology: node[0] - /\ - s f - / \ + / | + s f + / | node[1] node[3] - / \ - s f - / \ - node[2] node[4] - \ / + / | s f - \ / - node[5] + / | + node[2] node[4] + \ | + s f + \ | + node[5] """ wfj = WorkflowJob.objects.create() jt = JobTemplate.objects.create(name='test-jt') From 07db7a41b32ccac15be60654b1525a2e1dd8e478 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 2 Nov 2018 15:57:58 -0400 Subject: [PATCH 33/99] more flake8 --- awx/main/tests/functional/models/test_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index d6f79b8ec8..87ddc8747c 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -104,7 +104,7 @@ class TestWorkflowDNR(): @pytest.fixture def workflow_job_fn(self): def fn(states=['new', 'new', 'new', 'new', 'new', 'new']): - """ + r""" Workflow topology: node[0] / | From 7b95d2114d8aa7a934cb733bdb40d6c2b9132bf2 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 6 Nov 2018 17:04:44 -0500 Subject: [PATCH 34/99] Implements workflow convergence without proper layout --- .../features/templates/templates.strings.js | 9 +- awx/ui/client/src/templates/main.js | 2 - .../workflow-chart/workflow-chart.block.less | 35 +- .../workflow-chart.directive.js | 1091 ++++++++-------- .../forms/workflow-link-form.controller.js | 4 +- .../forms/workflow-link-form.directive.js | 3 +- .../forms/workflow-link-form.partial.html | 21 +- .../forms/workflow-node-form.controller.js | 318 ++--- .../forms/workflow-node-form.directive.js | 3 +- .../forms/workflow-node-form.partial.html | 4 +- .../workflow-maker.controller.js | 1152 ++++++++++------- .../workflow-maker.partial.html | 23 +- .../templates/workflows/workflow.service.js | 294 ----- .../workflow-results.controller.js | 122 +- .../workflow-results.partial.html | 9 +- 15 files changed, 1606 insertions(+), 1484 deletions(-) delete mode 100644 awx/ui/client/src/templates/workflows/workflow.service.js diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 5e8afaf8bd..baaae1f783 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -124,9 +124,12 @@ function TemplatesStrings (BaseString) { INVENTORY_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden by the parent workflow inventory.'), INVENTORY_PROMPT_WILL_OVERRIDE: t.s('The inventory of this node will be overridden if a parent workflow inventory is provided at launch.'), INVENTORY_PROMPT_WILL_NOT_OVERRIDE: t.s('The inventory of this node will not be overridden if a parent workflow inventory is provided at launch.'), - EDIT_LINK: ({ parentName, childName }) => t.s('EDIT LINK | {{parentName}} to {{childName}}', { parentName, childName }), - VIEW_LINK: ({ parentName, childName }) => t.s('VIEW LINK | {{parentName}} to {{childName}}', { parentName, childName }) - } + ADD_LINK: t.s('ADD LINK'), + EDIT_LINK: t.s('EDIT LINK'), + VIEW_LINK: t.s('VIEW LINK'), + NEW_LINK: t.s('Please click on an available node to form a new link.'), + UNLINK: t.s('UNLINK') + }; } TemplatesStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 1737771029..60e20aafc8 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -14,7 +14,6 @@ import prompt from './prompt/main'; import workflowChart from './workflows/workflow-chart/main'; import workflowMaker from './workflows/workflow-maker/main'; import workflowControls from './workflows/workflow-controls/main'; -import workflowService from './workflows/workflow.service'; import WorkflowForm from './workflows.form'; import InventorySourcesList from './inventory-sources.list'; import TemplateList from './templates.list'; @@ -35,7 +34,6 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p workflowChart.name, workflowMaker.name, workflowControls.name ]) .service('TemplatesService', templatesService) - .service('WorkflowService', workflowService) .factory('WorkflowForm', WorkflowForm) // TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc. .factory('TemplateList', TemplateList) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index b2deec2bf3..0d5130a418 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -16,7 +16,7 @@ .WorkflowChart-linkHovering .WorkflowChart-linkOverlay { cursor: pointer; opacity: 1; - fill: @cgrey; + fill: #E1E1E1; } .WorkflowChart-linkHovering .WorkflowChart-linkPath { @@ -27,9 +27,11 @@ .WorkflowChart-link polygon, .WorkflowChart-link .WorkflowChart-betweenNodesIcon, .WorkflowChart-node .WorkflowChart-nodeAddCircle, +.WorkflowChart-node .WorkflowChart-linkCircle, .WorkflowChart-node .WorkflowChart-nodeRemoveCircle, .WorkflowChart-node .WorkflowChart-nodeAddIcon, -.WorkflowChart-node .WorkflowChart-nodeRemoveIcon { +.WorkflowChart-node .WorkflowChart-nodeRemoveIcon, +.WorkflowChart-node .WorkflowChart-nodeLinkIcon { opacity: 0; } @@ -41,6 +43,14 @@ fill: @default-succ-hov; } +.WorkflowChart-node .WorkflowChart-linkCircle { + fill: @default-link; +} + +.WorkflowChart-linkCircle.WorkflowChart-linkButtonHovering { + fill: @default-link-hov; +} + .WorkflowChart-node .WorkflowChart-nodeRemoveCircle { fill: @default-err; } @@ -53,20 +63,27 @@ fill: @default-secondary-bg; } -.WorkflowChart-rect.WorkflowChart-placeholder { +.WorkflowChart-rect.WorkflowChart-isNodeBeingAdded { stroke-dasharray: 3; } -.WorkflowChart-node .WorkflowChart-transparentRect { +.WorkflowChart-node .WorkflowChart-nodeOverlay--transparent { fill: @default-bg; opacity: 0; } +.WorkflowChart-node .WorkflowChart-nodeOverlay--disabled { + fill: @default-dark; + opacity: 0.2; +} + .WorkflowChart-alwaysShowAdd circle, .WorkflowChart-alwaysShowAdd path, .WorkflowChart-alwaysShowAdd .WorkflowChart-betweenNodesIcon, .WorkflowChart-nodeHovering .WorkflowChart-nodeAddCircle, .WorkflowChart-nodeHovering .WorkflowChart-nodeAddIcon, +.WorkflowChart-nodeHovering .WorkflowChart-linkCircle, +.WorkflowChart-nodeHovering .WorkflowChart-nodeLinkIcon, .WorkflowChart-nodeHovering .WorkflowChart-nodeRemoveCircle, .WorkflowChart-nodeHovering .WorkflowChart-nodeRemoveIcon, .WorkflowChart-addHovering circle, @@ -76,7 +93,7 @@ opacity: 1; } -.WorkflowChart-link.WorkflowChart-placeholder { +.WorkflowChart-link.WorkflowChart-isNodeBeingAdded { stroke-dasharray: 3; } @@ -184,3 +201,11 @@ .WorkflowChart-dashedNode { stroke-dasharray: 5,5; } + +.WorkflowChart-nodeLinkIcon { + color: @default-bg; +} + +.WorkflowChart-nodeHovering .WorkflowChart-addLinkCircle { + fill: @default-link; +} diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 59feb22066..cb4b392e7c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -9,13 +9,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return { scope: { - treeData: '=', - canAddWorkflowJobTemplate: '=', - workflowJobTemplateObj: '=', - addNode: '&', + treeState: '=', + readOnly: '<', + addNodeWithoutChild: '&', + addNodeWithChild: '&', editNode: '&', deleteNode: '&', editLink: '&', + selectNodeForLinking: '&', workflowZoomed: '&', mode: '@' }, @@ -23,22 +24,19 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge link: function(scope, element) { let marginLeft = 20, - i = 0, nodeW = 180, nodeH = 60, rootW = 60, rootH = 40, startNodeOffsetY = scope.mode === 'details' ? 17 : 10, - verticalSpaceBetweenNodes = 20, maxNodeTextLength = 27, windowHeight, windowWidth, - tree, - line, zoomObj, baseSvg, svgGroup, - graphLoaded; + graphLoaded, + force; scope.dimensionsSet = false; @@ -56,16 +54,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); function init() { - tree = d3.layout.tree() - .nodeSize([nodeH + verticalSpaceBetweenNodes,nodeW]) - .separation(function(a, b) { - // This should tighten up some of the other nodes so there's not so much wasted space - return a.parent === b.parent ? 1 : 1.25; - }); - - line = d3.svg.line() - .x(function(d){return d.x;}) - .y(function(d){return d.y;}); + force = d3.layout.force() + .gravity(0) + .charge(-60) + .linkDistance(300) + .size([windowHeight, windowWidth]); zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); @@ -104,27 +97,6 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return dimensions; } - function lineData(d){ - - let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; - let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; - let targetX = d.target.y; - let targetY = d.target.x + nodeH / 2; - - let points = [ - { - x: sourceX, - y: sourceY - }, - { - x: targetX, - y: targetY - } - ]; - - return line(points); - } - // TODO: this function is hacky and we need to come up with a better solution // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 function wrap(text) { @@ -229,27 +201,384 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); zoomObj.translate([marginLeft - scaleToFit*marginLeft, windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); - } function update() { - let userCanAddEdit = (scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; if(scope.dimensionsSet) { - // Declare the nodes - let nodes = tree.nodes(scope.treeData), - links = tree.links(nodes); + let links = svgGroup.selectAll(".WorkflowChart-link") + .data(scope.treeState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); - let node = svgGroup.selectAll("g.WorkflowChart-node") - .data(nodes, function(d) { - d.y = d.depth * 240; - return d.id || (d.id = ++i); + // Remove any stale links + links.exit().remove(); + + // Update existing links + baseSvg.selectAll(".WorkflowChart-link") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + + baseSvg.selectAll(".WorkflowChart-linkPath") + .attr("class", function(d) { + return (d.source.isNodeBeingAdded || d.target.isNodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; + }) + .attr('stroke', function(d) { + let edgeType = d.edgeType; + if(edgeType) { + if(edgeType === "failure") { + return "#d9534f"; + } else if(edgeType === "success") { + return "#5cb85c"; + } else if(edgeType === "always"){ + return "#337ab7"; + } else if (edgeType === "placeholder") { + return "#B9B9B9"; + } + } + else { + return "#D7D7D7"; + } }); - let nodeEnter = node.enter().append("g") - .attr("class", "WorkflowChart-node") - .attr("id", function(d){return "node-" + d.id;}) - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); + baseSvg.selectAll(".WorkflowChart-linkOverlay") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) + .attr("class", function(d) { + let linkClasses = ["WorkflowChart-linkOverlay"]; + if (d.isLinkBeingEdited) { + linkClasses.push("WorkflowChart-link--active"); + } + return linkClasses.join(' '); + }); + + baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) + .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") + .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }); + + + // Add any new links + let linkEnter = links.enter().append("g") + .attr("class", "WorkflowChart-link") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + + linkEnter.append("polygon", "g") + .attr("class", function(d) { + let linkClasses = ["WorkflowChart-linkOverlay"]; + if (d.isLinkBeingEdited) { + linkClasses.push("WorkflowChart-link--active"); + } + return linkClasses.join(' '); + }) + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) + .call(edit_link) + .on("mouseover", function(d) { + if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-linkHovering", true); + + let xPos, yPos, arrowClass; + if (d.source.x === d.target.x) { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + arrowClass = 'WorkflowChart-tooltipArrow--down'; + } else { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + arrowClass = 'WorkflowChart-tooltipArrow--right'; + } + + let edgeTypeLabel; + + switch(d.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + + let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + + svgGroup.append("foreignObject") + .attr("transform", `translate(${xPos},${yPos})`) + .attr("width", 100) + .attr("height", 60) + .attr("class", "WorkflowChart-tooltip") + .html(function(){ + return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; + }); + } + + }) + .on("mouseout", function(d){ + if(!d.source.isStartNode && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-linkHovering", false); + } + $('.WorkflowChart-tooltip').remove(); + }); + + // Add entering links in the parent’s old position. + linkEnter.append("line") + .attr("class", function(d) { + return (d.source.isNodeBeingAdded || d.target.isNodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; + }) + .call(edit_link) + .on("mouseenter", function(d) { + if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-linkHovering", true); + + let xPos, yPos, arrowClass; + if (d.source.x === d.target.x) { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + arrowClass = 'WorkflowChart-tooltipArrow--down'; + } else { + xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; + yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + arrowClass = 'WorkflowChart-tooltipArrow--right'; + } + + let edgeTypeLabel; + + switch(d.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + + let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + + svgGroup.append("foreignObject") + .attr("transform", `translate(${xPos},${yPos})`) + .attr("width", 100) + .attr("height", 60) + .attr("class", "WorkflowChart-tooltip") + .html(function(){ + return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; + }); + } + }) + .on("mouseleave", function(d){ + if(!d.source.isStartNode && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-linkHovering", false); + } + $('.WorkflowChart-tooltip').remove(); + }) + .attr('stroke', function(d) { + let edgeType = d.edgeType; + if(d.edgeType) { + if(edgeType === "failure") { + return "#d9534f"; + } else if(edgeType === "success") { + return "#5cb85c"; + } else if(edgeType === "always"){ + return "#337ab7"; + } else if (edgeType === "placeholder") { + return "#B9B9B9"; + } + } + else { + return "#D7D7D7"; + } + }); + + linkEnter.append("circle") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) + .attr("r", 10) + .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") + .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .call(add_node_with_child) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-addHovering", false); + }); + + linkEnter.append("path") + .attr("class", "WorkflowChart-betweenNodesIcon") + .style("fill", "white") + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .call(add_node_with_child) + .on("mouseover", function(d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("WorkflowChart-addHovering", false); + }); + + // Create references to all the link elements so that they can be transitioned + // properly in the tick function + let linkLines = svgGroup.selectAll(".WorkflowChart-link line"); + let linkPolygons = svgGroup.selectAll(".WorkflowChart-link polygon"); + let linkAddBetweenCircle = svgGroup.selectAll(".WorkflowChart-link circle"); + let linkAddBetweenIcon = svgGroup.selectAll(".WorkflowChart-betweenNodesIcon"); + + let nodes = svgGroup.selectAll('.WorkflowChart-node') + .data(scope.treeState.arrayOfNodesForChart, function(d) { return d.id; }); + + // Remove any stale nodes + nodes.exit().remove(); + + // Update existing nodes + baseSvg.selectAll(".WorkflowChart-nodeAddCircle") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeAddIcon") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-linkCircle") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeLinkIcon") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeRemoveCircle") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeRemoveIcon") + .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-rect") + .attr('stroke', function(d) { + if(d.job && d.job.status) { + if(d.job.status === "successful"){ + return "#5cb85c"; + } + else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } + else { + return "#D7D7D7"; + } + } + else { + return "#D7D7D7"; + } + }) + .attr("class", function(d) { + let classString = d.isNodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; + classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + return classString; + }); + + baseSvg.selectAll(".WorkflowChart-nodeOverlay") + .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }); + + baseSvg.selectAll(".WorkflowChart-nodeTypeCircle") + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-nodeTypeLetter") + .text(function (d) { + return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + }) + .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-nodeStatus") + .attr("class", function(d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if(d.job){ + switch(d.job.status) { + case "pending": + statusClass += "WorkflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "WorkflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "WorkflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "WorkflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "WorkflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "WorkflowChart-nodeStatus--failed"; + break; + case "canceled": + statusClass += "WorkflowChart-nodeStatus--canceled"; + break; + } + } + + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .transition() + .duration(0) + .attr("r", 6) + .each(function(d) { + if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { + // Pulse the circle + var circle = d3.select(this); + (function repeat() { + circle = circle.transition() + .duration(2000) + .attr("r", 6) + .transition() + .duration(2000) + .attr("r", 0) + .ease('sine') + .each("end", repeat); + })(); + } + }); + + baseSvg.selectAll(".WorkflowChart-nameText") + .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) + .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) + .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) + .text(function (d) { + const name = _.get(d, 'unifiedJobTemplate.name'); + return name ? wrap(name) : ""; + }); + + baseSvg.selectAll(".WorkflowChart-detailsLink") + .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-deletedText") + .style("display", function(d){ return d.unifiedJobTemplate || d.isNodeBeingAdded ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-activeNode") + .style("display", function(d) { return d.isNodeBeingEdited ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-elapsed") + .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + + baseSvg.selectAll(".WorkflowChart-addLinkCircle") + .attr("fill", function(d) { return scope.treeState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) + .style("display", function(d) { return scope.treeState.isLinkMode && !d.isInvalidLinkTarget ? null : "none"; }); + + // Add new nodes + const nodeEnter = nodes + .enter() + .append('g') + .attr("class", "WorkflowChart-node") + .attr("id", function(d){return "node-" + d.id;}); nodeEnter.each(function(d) { let thisNode = d3.select(this); @@ -275,16 +604,22 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("ry", 5) .attr("fill", "#5cb85c") .attr("class", "WorkflowChart-rootNode") - .call(add_node); + .call(add_node_without_child); thisNode.append("text") .attr("x", 13) .attr("y", 30) .attr("dy", ".35em") .attr("class", "WorkflowChart-startText") .text(function () { return TemplatesStrings.get('workflow_maker.START'); }) - .call(add_node); + .call(add_node_without_child); } else { + thisNode.append("circle") + .attr("cy", nodeH/2) + .attr("cx", nodeW) + .attr("r", 8) + .attr("class", "WorkflowChart-addLinkCircle") + .style("display", function() { return scope.treeState.isLinkMode ? null : "none"; }); thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) @@ -308,15 +643,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .attr('stroke-width', "2px") .attr("class", function(d) { - let classString = d.placeholder ? "WorkflowChart-rect WorkflowChart-placeholder" : "WorkflowChart-rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + let classString = d.isNodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; + classString += !_.get(d, 'unifiedJobTemplate.name') ? " WorkflowChart-dashedNode" : ""; return classString; }); thisNode.append("path") .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) .attr("class", "WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); + .style("display", function(d) { return d.isNodeBeingEdited ? null : "none"; }); thisNode.append("text") .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) @@ -325,8 +660,9 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - }).each(wrap); + const name = _.get(d, 'unifiedJobTemplate.name'); + return name ? wrap(name) : ""; + }); thisNode.append("foreignObject") .attr("x", 62) @@ -337,7 +673,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .html(function () { return `${TemplatesStrings.get('workflow_maker.DELETED')}`; }) - .style("display", function(d) { return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); + .style("display", function(d) { return d.unifiedJobTemplate || d.isNodeBeingAdded ? "none" : null; }); thisNode.append("circle") .attr("cy", nodeH) @@ -398,8 +734,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) - .attr("class", "WorkflowChart-transparentRect") - .call(edit_node) + .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }) + .call(node_click) .on("mouseover", function(d) { if(!d.isStartNode) { let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; @@ -416,7 +752,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // the user is hovering over is at the very end of the list. This way the tooltip will appear on top // of all other nodes. svgGroup.selectAll("g.WorkflowChart-node").sort(function (a) { - return (a.id !== d.id) ? -1 : 1; + return (a.index !== d.index) ? -1 : 1; }); // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place // it properly on the workflow @@ -436,12 +772,35 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return "
" + $filter('sanitize')(resourceName) + "
"; }); } + + if (scope.treeState.isLinkMode && !d.isInvalidLinkTarget && scope.treeState.addLinkSource !== d.id) { + let sourceNode = d3.select(`#node-${scope.treeState.addLinkSource}`); + const sourceNodeX = d3.transform(sourceNode.attr("transform")).translate[0]; + const sourceNodeY = d3.transform(sourceNode.attr("transform")).translate[1]; + + let targetNode = d3.select(`#node-${d.id}`); + const targetNodeX = d3.transform(targetNode.attr("transform")).translate[0]; + const targetNodeY = d3.transform(targetNode.attr("transform")).translate[1]; + + $('.WorkflowChart-potentialLink').remove(); + + svgGroup.insert("line", '.WorkflowChart-node') + .attr("class", "WorkflowChart-potentialLink") + .attr("x1", sourceNodeX + nodeW/2) + .attr("x2", targetNodeX + nodeW/2) + .attr("y1", sourceNodeY + nodeH/2) + .attr("y2", targetNodeY + nodeH/2) + .style("stroke-dasharray","5,5") + .style("stroke-width", "2") + .style("stroke", "#D7D7D7"); + } d3.select("#node-" + d.id) .classed("WorkflowChart-nodeHovering", true); } }) .on("mouseout", function(d){ $('.WorkflowChart-tooltip').remove(); + $('.WorkflowChart-potentialLink').remove(); if(!d.isStartNode) { d3.select("#node-" + d.id) .classed("WorkflowChart-nodeHovering", false); @@ -462,8 +821,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cx", nodeW) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-nodeAddCircle") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - .call(add_node) + .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .call(add_node_without_child) .on("mouseover", function(d) { d3.select("#node-" + d.id) .classed("WorkflowChart-nodeHovering", true); @@ -484,8 +843,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - .call(add_node) + .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .call(add_node_without_child) .on("mouseover", function(d) { d3.select("#node-" + d.id) .classed("WorkflowChart-nodeHovering", true); @@ -498,13 +857,57 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge d3.select("#node-" + d.id + "-add") .classed("WorkflowChart-addHovering", false); }); + thisNode.append("circle") + .attr("id", function(d){return "node-" + d.id + "-link";}) + .attr("cx", nodeW) + .attr("cy", nodeH/2) + .attr("r", 10) + .attr("class", "WorkflowChart-linkCircle") + .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .call(add_link) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-link") + .classed("WorkflowChart-linkButtonHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + d3.select("#node-" + d.id + "-link") + .classed("WorkflowChart-linkButtonHovering", false); + }); + // TODO: clean up the placement of this icon... this works but it's not + // clean + thisNode.append("foreignObject") + .attr("x", nodeW - 6) + .attr("y", nodeH/2 - 9) + .style("font-size","14px") + .html(function () { + return ``; + }) + .attr("class", "WorkflowChart-nodeLinkIcon") + .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .call(add_link) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-link") + .classed("WorkflowChart-linkButtonHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + d3.select("#node-" + d.id + "-link") + .classed("WorkflowChart-linkButtonHovering", false); + }); thisNode.append("circle") .attr("id", function(d){return "node-" + d.id + "-remove";}) .attr("cx", nodeW) .attr("cy", nodeH) .attr("r", 10) .attr("class", "WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .style("display", function(d) { return (d.isStartNode || d.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -526,7 +929,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) + .style("display", function(d) { return (d.isStartNode || d.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -600,37 +1003,32 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }); - node.exit().remove(); - - if(nodes && nodes.length > 1 && !graphLoaded) { - zoomToFitChart(); - } + // if(scope.treeState.arrayOfNodesForChart && scope.treeState.arrayOfNodesForChart > 1 && !graphLoaded) { + // zoomToFitChart(); + // } graphLoaded = true; - let link = svgGroup.selectAll("g.WorkflowChart-link") - .data(links, function(d) { - return d.source.id + "-" + d.target.id; - }); + // This will make sure that all the link elements appear before the nodes in the dom + svgGroup.selectAll(".WorkflowChart-node").order(); - let linkEnter = link.enter().append("g") - .attr("class", "WorkflowChart-link") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + let tick = (e) => { + var k = 6 * e.alpha; - linkEnter.append("polygon", "g") - .attr("class", function(d) { - let linkClasses = ["WorkflowChart-linkOverlay"]; - if (d.source.isLinkEditParent && d.target.isLinkEditChild) { - linkClasses.push("WorkflowChart-link--active"); - } - return linkClasses.join(' '); - }) - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) + // TODO: replace hard-coded 60 here + linkLines + .each(function(d) { d.source.y -= k; d.target.y += k; }) + .attr("x1", function(d) { return d.target.y; }) + .attr("y1", function(d) { return d.target.x + (nodeH/2); }) + .attr("x2", function(d) { return d.source.index === 0 ? (scope.mode === 'details' ? d.source.y + 25 : d.source.y + 60) : (d.source.y + nodeW); }) + .attr("y2", function(d) { return d.source.x + (nodeH/2); }); + + linkPolygons .attr("points",function(d) { - let x1 = d.source.y + nodeW; - let y1 = d.source.x + nodeH / 2; - let x2 = d.target.y; - let y2 = d.target.x + nodeH / 2; + let x1 = d.target.y; + let y1 = d.target.x + (nodeH/2); + let x2 = d.source.index === 0 ? (d.source.y + 60) : (d.source.y + nodeW); + let y2 = d.source.x + (nodeH/2); let slope = (y2 - y1)/(x2-x1); let yIntercept = y1 - slope*x1; let orthogonalDistance = 8; @@ -641,419 +1039,38 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); return [pt1, pt2, pt3, pt4].join(" "); - }) - .call(edit_link) - .on("mouseover", function(d) { - if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details') { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-linkHovering", true); - - let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; - arrowClass = 'WorkflowChart-tooltipArrow--down'; - } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; - arrowClass = 'WorkflowChart-tooltipArrow--right'; - } - - let edgeTypeLabel; - - switch(d.target.edgeType) { - case "always": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); - break; - case "success": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); - break; - case "failure": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); - break; - } - - let linkInstructionText = _.get(scope, 'workflowJobTemplateObj.summary_fields.user_capabilities.edit') ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); - - linkEnter.append("foreignObject") - .attr("x", xPos) - .attr("y", yPos) - .attr("width", 100) - .attr("height", 60) - .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; - }); - } - - }) - .on("mouseout", function(d){ - if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-linkHovering", false); - } - $('.WorkflowChart-tooltip').remove(); }); - // Add entering links in the parent’s old position. - linkEnter.append("path", "g") - .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "WorkflowChart-linkPath WorkflowChart-placeholder" : "WorkflowChart-linkPath"; - }) - .attr("d", lineData) - .call(edit_link) - .on("mouseenter", function(d) { - if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details') { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-linkHovering", true); + linkAddBetweenCircle + .attr("cx", function(d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function(d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }); - let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; - arrowClass = 'WorkflowChart-tooltipArrow--down'; - } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; - arrowClass = 'WorkflowChart-tooltipArrow--right'; - } + linkAddBetweenIcon + .attr("transform", function(d) { + let translate; + if(d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; + } + else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }); - let edgeTypeLabel; + nodes + .attr("transform", function(d) { + return "translate(" + d.y + "," + d.x + ")"; }); + }; - switch(d.target.edgeType) { - case "always": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); - break; - case "success": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); - break; - case "failure": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); - break; - } - - let linkInstructionText = _.get(scope, 'workflowJobTemplateObj.summary_fields.user_capabilities.edit') ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); - - linkEnter.append("foreignObject") - .attr("x", xPos) - .attr("y", yPos) - .attr("width", 100) - .attr("height", 60) - .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; - }); - } - }) - .on("mouseleave", function(d){ - if(!d.source.isStartNode && !d.target.placeholder && scope.mode !== 'details') { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-linkHovering", false); - } - $('.WorkflowChart-tooltip').remove(); - }) - .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { - return "#d9534f"; - } - else if(d.target.edgeType === "success") { - return "#5cb85c"; - } - else if(d.target.edgeType === "always"){ - return "#337ab7"; - } - } - else { - return "#D7D7D7"; - } - }); - - linkEnter.append("circle") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }) - .attr("r", 10) - .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-addHovering", false); - }); - - linkEnter.append("path") - .attr("class", "WorkflowChart-betweenNodesIcon") - .style("fill", "white") - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("WorkflowChart-addHovering", false); - }); - - link.exit().remove(); - - // Transition nodes and links to their new positions. - let t = baseSvg.transition(); - - t.selectAll(".WorkflowChart-nodeAddCircle") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - - t.selectAll(".WorkflowChart-nodeAddIcon") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - - t.selectAll(".WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - - t.selectAll(".WorkflowChart-nodeRemoveIcon") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - - t.selectAll(".WorkflowChart-linkPath") - .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "WorkflowChart-linkPath WorkflowChart-placeholder" : "WorkflowChart-linkPath"; - }) - .attr("d", lineData) - .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { - return "#d9534f"; - } - else if(d.target.edgeType === "success") { - return "#5cb85c"; - } - else if(d.target.edgeType === "always"){ - return "#337ab7"; - } - } - else { - return "#D7D7D7"; - } - }); - - t.selectAll(".WorkflowChart-circleBetweenNodes") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); - - t.selectAll(".WorkflowChart-linkOverlay") - .attr("class", function(d) { - let linkClasses = ["WorkflowChart-linkOverlay"]; - if (d.source.isLinkEditParent && d.target.isLinkEditChild) { - linkClasses.push("WorkflowChart-link--active"); - } - return linkClasses.join(' '); - }) - .attr("points",function(d) { - let x1 = d.source.y + nodeW; - let y1 = d.source.x + nodeH / 2; - let x2 = d.target.y; - let y2 = d.target.x + nodeH / 2; - let slope = (y2 - y1)/(x2-x1); - let yIntercept = y1 - slope*x1; - let orthogonalDistance = 8; - - const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - - return [pt1, pt2, pt3, pt4].join(" "); - }); - - t.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); - - t.selectAll(".WorkflowChart-rect") - .attr('stroke', function(d) { - if(d.job && d.job.status) { - if(d.job.status === "successful"){ - return "#5cb85c"; - } - else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } - else { - return "#D7D7D7"; - } - } - else { - return "#D7D7D7"; - } - }) - .attr("class", function(d) { - let classString = d.placeholder ? "WorkflowChart-rect WorkflowChart-placeholder" : "WorkflowChart-rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); - - t.selectAll(".WorkflowChart-node") - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); - - t.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); - - t.selectAll(".WorkflowChart-nodeTypeLetter") - .text(function (d) { - let nodeTypeLetter = ""; - if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { - switch (d.unifiedJobTemplate.type) { - case "project": - nodeTypeLetter = "P"; - break; - case "inventory_source": - nodeTypeLetter = "I"; - break; - case "workflow_job_template": - nodeTypeLetter = "W"; - break; - } - } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { - switch (d.unifiedJobTemplate.unified_job_type) { - case "project_update": - nodeTypeLetter = "P"; - break; - case "inventory_update": - nodeTypeLetter = "I"; - break; - case "workflow_job": - nodeTypeLetter = "W"; - break; - } - } - return nodeTypeLetter; - }) - .style("display", function (d) { - return d.unifiedJobTemplate && - (d.unifiedJobTemplate.type === "project" || - d.unifiedJobTemplate.unified_job_type === "project_update" || - d.unifiedJobTemplate.type === "inventory_source" || - d.unifiedJobTemplate.unified_job_type === "inventory_update" || - d.unifiedJobTemplate.type === "workflow_job_template" || - d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; - }); - - t.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function(d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if(d.job){ - switch(d.job.status) { - case "pending": - statusClass += "WorkflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "WorkflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "WorkflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "WorkflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "WorkflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "WorkflowChart-nodeStatus--failed"; - break; - case "canceled": - statusClass += "WorkflowChart-nodeStatus--canceled"; - break; - } - } - - return statusClass; - }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) - .transition() - .duration(0) - .attr("r", 6) - .each(function(d) { - if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { - // Pulse the circle - var circle = d3.select(this); - (function repeat() { - circle = circle.transition() - .duration(2000) - .attr("r", 6) - .transition() - .duration(2000) - .attr("r", 0) - .ease('sine') - .each("end", repeat); - })(); - } - }); - - t.selectAll(".WorkflowChart-nameText") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) - .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; - }); - - t.selectAll(".WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); - - t.selectAll(".WorkflowChart-deletedText") - .style("display", function(d){ return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); - - t.selectAll(".WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - - t.selectAll(".WorkflowChart-elapsed") - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); + force + .nodes(scope.treeState.arrayOfNodesForChart) + .links(scope.treeState.arrayOfLinksForChart) + .on("tick", tick) + .start(); } else if(!scope.watchDimensionsSet){ scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ @@ -1066,23 +1083,21 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } } - function add_node() { + function add_node_without_child() { this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: false + if(!scope.readOnly && !scope.treeState.isLinkMode) { + scope.addNodeWithoutChild({ + parent: d }); } }); } - function add_node_between() { + function add_node_with_child() { this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: true + if(!scope.readOnly && !scope.treeState.isLinkMode) { + scope.addNodeWithChild({ + link: d }); } }); @@ -1090,7 +1105,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function remove_node() { this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + if(!d.isStartNode && !scope.readOnly && !scope.treeState.isLinkMode) { scope.deleteNode({ nodeToDelete: d }); @@ -1098,30 +1113,41 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); } - function edit_node() { + function node_click() { this.on("click", function(d) { - if(d.canEdit){ - scope.editNode({ - nodeToEdit: d - }); + if(!d.isStartNode && !scope.readOnly){ + if(scope.treeState.isLinkMode && !d.isInvalidLinkTarget) { + $('.WorkflowChart-potentialLink').remove(); + scope.selectNodeForLinking({ + nodeToStartLink: d + }); + } else if(!scope.treeState.isLinkMode) { + scope.editNode({ + nodeToEdit: d + }); + } + } }); } function edit_link() { this.on("click", function(d) { - if(!d.source.isStartNode && !d.source.placeholder && !d.target.placeholder && scope.mode !== 'details'){ + if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details'){ scope.editLink({ - parentId: d.source.id, - childId: d.target.id + linkToEdit: d }); } }); } - function link_node() { + function add_link() { this.on("click", function(d) { - alert('this does not work, don\'t click it'); + if (!scope.readOnly && !scope.treeState.isLinkMode) { + scope.selectNodeForLinking({ + nodeToStartLink: d + }); + } }); } @@ -1170,15 +1196,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); } - scope.$watch('canAddWorkflowJobTemplate', function() { - // Redraw the graph if permissions change - if(scope.treeData) { - update(); - } - }); - scope.$on('refreshWorkflowChart', function(){ - if(scope.treeData) { + if(scope.treeState) { update(); } }); @@ -1199,10 +1218,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge zoomToFitChart(); }); - let clearWatchTreeData = scope.$watch('treeData', function(newVal) { + let clearWatchTreeState = scope.$watch('treeState.arrayOfNodesForChart', function(newVal) { if(newVal) { + // scope.treeState.arrayOfNodesForChart + update(); - clearWatchTreeData(); + clearWatchTreeState(); } }); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js index 7c95cd97dd..bbf8f095c3 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js @@ -4,8 +4,8 @@ * All Rights Reserved *************************************************/ -export default ['$scope', 'TemplatesStrings', 'CreateSelect2', '$timeout', - function($scope, TemplatesStrings, CreateSelect2, $timeout) { +export default ['$scope', 'TemplatesStrings', 'CreateSelect2', + function($scope, TemplatesStrings, CreateSelect2) { $scope.strings = TemplatesStrings; $scope.edgeTypeOptions = [ diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js index 8591b9a728..ee0a447a92 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js @@ -13,7 +13,8 @@ export default ['templateUrl', linkConfig: '<', readOnly: '<', cancel: '&', - select: '&' + select: '&', + unlink: '&' }, restrict: 'E', templateUrl: templateUrl('templates/workflows/workflow-maker/forms/workflow-link-form'), diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html index a09dcd0618..2bf0315447 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -1,6 +1,8 @@ -
{{readOnly ? strings.get('workflow_maker.VIEW_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) : strings.get('workflow_maker.EDIT_LINK', {parentName: linkConfig.parent.name, childName: linkConfig.child.name}) }}
-
-
+
{{readOnly ? strings.get('workflow_maker.VIEW_LINK') : (linkConfig.mode === 'add' ? strings.get('workflow_maker.ADD_LINK') : strings.get('workflow_maker.EDIT_LINK')) }} | {{linkConfig.parent.name}} {{linkConfig.child ? 'to ' + linkConfig.child.name : ''}}
+
+
+
{{:: strings.get('workflow_maker.NEW_LINK')}}
+
-
-
- - - -
+ +
+
+ + + +
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 2310c067bf..08b1592f3d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -5,11 +5,11 @@ *************************************************/ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', - 'WorkflowService', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', - 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', + 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', + 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, - WorkflowService, TemplatesStrings, CreateSelect2, Empty, generateList, qs, - GetBasePath, TemplateList, ProjectList, InventorySourcesList + TemplatesStrings, CreateSelect2, Empty, generateList, qs, + GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors ) { let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; @@ -55,48 +55,6 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService projectList.disableRowValue = 'readOnly'; $scope.projectList = projectList; - $scope.$watch('node', (newNode, oldNode) => { - if (oldNode.id !== newNode.id) { - setupNodeForm(); - } - }); - - $scope.$watchGroup(['templates', 'projects', 'inventory_sources', 'activeTab'], () => { - // TODO: make this more concise - switch($scope.activeTab) { - case 'jobs': - $scope.templates.forEach(function(row, i) { - if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { - $scope.templates[i].checked = 1; - } - else { - $scope.templates[i].checked = 0; - } - }); - break; - case 'project_syncs': - $scope.projects.forEach(function(row, i) { - if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { - $scope.projects[i].checked = 1; - } - else { - $scope.projects[i].checked = 0; - } - }); - break; - case 'inventory_syncs': - $scope.inventory_sources.forEach(function(row, i) { - if(_.hasIn($scope, 'node.unifiedJobTemplate.id') && row.id === $scope.node.unifiedJobTemplate.id) { - $scope.inventory_sources[i].checked = 1; - } - else { - $scope.inventory_sources[i].checked = 0; - } - }); - break; - } - }); - const checkCredentialsForRequiredPasswords = () => { let credentialRequiresPassword = false; $scope.promptData.prompts.credentials.value.forEach((credential) => { @@ -135,14 +93,44 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } }; + const finishConfiguringAdd = () => { + $scope.activeTab = "jobs"; + const alwaysOption = { + label: $scope.strings.get('workflow_maker.ALWAYS'), + value: 'always' + }; + const successOption = { + label: $scope.strings.get('workflow_maker.ON_SUCCESS'), + value: 'success' + }; + const failureOption = { + label: $scope.strings.get('workflow_maker.ON_FAILURE'), + value: 'failure' + }; + $scope.edgeTypeOptions = [alwaysOption]; + switch($scope.nodeConfig.newNodeIsRoot) { + case true: + $scope.edgeType = alwaysOption; + break; + case false: + $scope.edgeType = successOption; + $scope.edgeTypeOptions.push(successOption, failureOption); + break; + } + CreateSelect2({ + element: '#workflow_node_edge_3', + multiple: false + }); + + $scope.nodeFormDataLoaded = true; + }; + const finishConfiguringEdit = () => { let jobTemplate = new JobTemplate(); - console.log($scope.node); - - if (!_.isEmpty($scope.node.promptData)) { - $scope.promptData = _.cloneDeep($scope.node.promptData); + if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) { + $scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); const launchConf = $scope.promptData.launchConf; if (!launchConf.survey_enabled && @@ -162,7 +150,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } else { $scope.showPromptButton = true; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'node.originalNodeObj.summary_fields.inventory')) { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { $scope.promptModalMissingReqFields = true; } else { $scope.promptModalMissingReqFields = false; @@ -170,13 +158,13 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } $scope.nodeFormDataLoaded = true; } else if ( - _.get($scope, 'node.unifiedJobTemplate.unified_job_type') === 'job_template' || - _.get($scope, 'node.unifiedJobTemplate.type') === 'job_template' + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.unified_job_type') === 'job_template' || + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === 'job_template' ) { - let promises = [jobTemplate.optionsLaunch($scope.node.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.node.unifiedJobTemplate.id)]; + let promises = [jobTemplate.optionsLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id), jobTemplate.getLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id)]; - if (_.has($scope, 'node.originalNodeObj.related.credentials')) { - Rest.setUrl($scope.node.originalNodeObj.related.credentials); + if (_.has($scope, 'nodeConfig.node.originalNodeObject.related.credentials')) { + Rest.setUrl($scope.nodeConfig.node.originalNodeObject.related.credentials); promises.push(Rest.get()); } @@ -189,7 +177,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService let prompts = PromptService.processPromptValues({ launchConf: responses[1].data, launchOptions: responses[0].data, - currentValues: $scope.node.originalNodeObj + currentValues: $scope.nodeConfig.node.originalNodeObject }); let defaultCredsWithoutOverrides = []; @@ -222,7 +210,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); - if ((!$scope.node.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.node.unifiedJobTemplate.project) { + if ((!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeConfig.node.fullUnifiedJobTemplateObject.project) { $scope.selectedTemplateInvalid = true; } else { $scope.selectedTemplateInvalid = false; @@ -264,7 +252,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } else { $scope.showPromptButton = true; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'node.originalNodeObj.summary_fields.inventory')) { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { $scope.promptModalMissingReqFields = true; } else { $scope.promptModalMissingReqFields = false; @@ -272,24 +260,24 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService if (responses[1].data.survey_enabled) { // go out and get the survey questions - jobTemplate.getSurveyQuestions($scope.node.unifiedJobTemplate.id) + jobTemplate.getSurveyQuestions($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) .then((surveyQuestionRes) => { let processed = PromptService.processSurveyQuestions({ surveyQuestions: surveyQuestionRes.data.spec, - extra_data: _.cloneDeep($scope.node.originalNodeObj.extra_data) + extra_data: _.cloneDeep($scope.nodeConfig.node.originalNodeObject.extra_data) }); $scope.missingSurveyValue = processed.missingSurveyValue; $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); - $scope.node.promptData = $scope.promptData = { + $scope.nodeConfig.node.promptData = $scope.promptData = { launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, surveyQuestions: surveyQuestionRes.data.spec, - template: $scope.node.unifiedJobTemplate.id + template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id }; surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { @@ -309,11 +297,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.nodeFormDataLoaded = true; }); } else { - $scope.node.promptData = $scope.promptData = { + $scope.nodeConfig.node.promptData = $scope.promptData = { launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, - template: $scope.node.unifiedJobTemplate.id + template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id }; checkCredentialsForRequiredPasswords(); @@ -328,12 +316,12 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.nodeFormDataLoaded = true; } - if (_.get($scope, 'node.unifiedJobTemplate')) { - if (_.get($scope, 'node.unifiedJobTemplate.type') === "job_template") { + if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) { + if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === "job_template") { $scope.activeTab = "jobs"; } - $scope.selectedTemplate = $scope.node.unifiedJobTemplate; + $scope.selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject; if ($scope.selectedTemplate.unified_job_type) { switch ($scope.selectedTemplate.unified_job_type) { @@ -364,79 +352,6 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.activeTab = "jobs"; } - if ($scope.mode === 'add') { - const alwaysOption = { - label: $scope.strings.get('workflow_maker.ALWAYS'), - value: 'always' - }; - const successOption = { - label: $scope.strings.get('workflow_maker.ON_SUCCESS'), - value: 'success' - }; - const failureOption = { - label: $scope.strings.get('workflow_maker.ON_FAILURE'), - value: 'failure' - }; - $scope.edgeTypeOptions = [alwaysOption]; - switch($scope.node.isRoot) { - case true: - $scope.edgeType = alwaysOption; - break; - case false: - $scope.edgeType = successOption; - $scope.edgeTypeOptions.push(successOption, failureOption); - break; - } - CreateSelect2({ - element: '#workflow_node_edge_3', - multiple: false - }); - - $scope.nodeFormDataLoaded = true; - } - }; - // Determine whether or not we need to go out and GET this nodes unified job template - // in order to determine whether or not prompt fields are needed - - $scope.openPromptModal = function() { - $scope.promptData.triggerModalOpen = true; - }; - - $scope.toggle_row = function(selectedRow) { - if (!$scope.readOnly) { - // TODO: make this more concise - switch($scope.activeTab) { - case 'jobs': - $scope.templates.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.templates[i].checked = 1; - - } else { - $scope.templates[i].checked = 0; - } - }); - break; - case 'project_syncs': - $scope.projects.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.projects[i].checked = 1; - } else { - $scope.projects[i].checked = 0; - } - }); - break; - case 'inventory_syncs': - $scope.inventory_sources.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.inventory_sources[i].checked = 1; - } else { - $scope.inventory_sources[i].checked = 0; - } - }); - break; - } - templateManuallySelected(selectedRow); - } }; const templateManuallySelected = (selectedTemplate) => { @@ -597,7 +512,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService page_size: '5', order_by: 'name', not__source: '' - } + }; $scope.inventory_sources = []; $scope.inventory_source_dataset = {}; @@ -612,26 +527,113 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $q.all(listPromises) .then(() => { - if (!$scope.node.isNew && !$scope.node.edited && $scope.node.unifiedJobTemplate && $scope.node.unifiedJobTemplate.unified_job_type && $scope.node.unifiedJobTemplate.unified_job_type === 'job') { - // This is a node that we got back from the api with an incomplete - // unified job template so we're going to pull down the whole object - - TemplatesService.getUnifiedJobTemplate($scope.node.unifiedJobTemplate.id) - .then(function(data) { - $scope.node.unifiedJobTemplate = _.clone(data.data.results[0]); - finishConfiguringEdit(); - }, function(error) { - ProcessErrors($scope, error.data, error.status, null, { - hdr: 'Error!', - msg: 'Failed to get unified job template. GET returned ' + - 'status: ' + error.status + if ($scope.nodeConfig.mode === "edit") { + // Make sure that we have the full unified job template object + if (!$scope.nodeConfig.node.fullUnifiedJobTemplate && _.get($scope, 'nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.unified_job_type') === 'job') { + // This is a node that we got back from the api with an incomplete + // unified job template so we're going to pull down the whole object + TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id) + .then(function({data}) { + $scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0]; + finishConfiguringEdit(); + }, function(error) { + ProcessErrors($scope, error.data, error.status, null, { + hdr: 'Error!', + msg: 'Failed to get unified job template. GET returned ' + + 'status: ' + error.status + }); }); - }); + } else { + finishConfiguringEdit(); + } } else { - finishConfiguringEdit(); + finishConfiguringAdd(); } }); - } + }; + + $scope.openPromptModal = function() { + $scope.promptData.triggerModalOpen = true; + }; + + $scope.toggle_row = function(selectedRow) { + if (!$scope.readOnly) { + // TODO: make this more concise + switch($scope.activeTab) { + case 'jobs': + $scope.templates.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.templates[i].checked = 1; + + } else { + $scope.templates[i].checked = 0; + } + }); + break; + case 'project_syncs': + $scope.projects.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.projects[i].checked = 1; + } else { + $scope.projects[i].checked = 0; + } + }); + break; + case 'inventory_syncs': + $scope.inventory_sources.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.inventory_sources[i].checked = 1; + } else { + $scope.inventory_sources[i].checked = 0; + } + }); + break; + } + templateManuallySelected(selectedRow); + } + }; + + $scope.$watch('nodeConfig.nodeId', (newNodeId, oldNodeId) => { + if (newNodeId !== oldNodeId) { + setupNodeForm(); + } + }); + + $scope.$watchGroup(['templates', 'projects', 'inventory_sources', 'activeTab'], () => { + // TODO: make this more concise + switch($scope.activeTab) { + case 'jobs': + $scope.templates.forEach(function(row, i) { + if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.templates[i].checked = 1; + } + else { + $scope.templates[i].checked = 0; + } + }); + break; + case 'project_syncs': + $scope.projects.forEach(function(row, i) { + if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.projects[i].checked = 1; + } + else { + $scope.projects[i].checked = 0; + } + }); + break; + case 'inventory_syncs': + $scope.inventory_sources.forEach(function(row, i) { + if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.inventory_sources[i].checked = 1; + } + else { + $scope.inventory_sources[i].checked = 0; + } + }); + break; + } + }); setupNodeForm(); } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js index 2a273a4e0f..119e88908d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js @@ -10,8 +10,7 @@ export default ['templateUrl', function(templateUrl) { return { scope: { - mode: '<', - node: '=', + nodeConfig: '<', cancel: '&', select: '&', readOnly: '<' diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index c6c239f405..4677547f47 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -1,5 +1,5 @@
-
{{mode === 'edit' ? node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
+
{{nodeConfig.mode === 'edit' ? node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
{{strings.get('workflow_maker.JOBS')}}
{{strings.get('workflow_maker.PROJECT_SYNC')}}
@@ -118,7 +118,7 @@ {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}
-
+
{{strings.get('workflow_maker.TOTAL_TEMPLATES')}} - +
- + +
- + - +
- +
diff --git a/awx/ui/client/src/templates/workflows/workflow.service.js b/awx/ui/client/src/templates/workflows/workflow.service.js deleted file mode 100644 index 6b5ef1e45a..0000000000 --- a/awx/ui/client/src/templates/workflows/workflow.service.js +++ /dev/null @@ -1,294 +0,0 @@ -export default ['$q', function($q){ - return { - searchTree: function(params) { - // params.element - // params.matchingId - // params.byNodeId - - let prospectiveId = params.byNodeId ? params.element.nodeId : params.element.id; - - if(prospectiveId === params.matchingId){ - return params.element; - }else if (params.element.children && params.element.children.length > 0){ - let result = null; - const thisService = this; - _.forEach(params.element.children, function(child) { - result = thisService.searchTree({ - element: child, - matchingId: params.matchingId, - byNodeId: params.byNodeId ? params.byNodeId : false - }); - if(result) { - return false; - } - }); - return result; - } - return null; - }, - removeNodeFromTree: function(params) { - // params.tree - // params.nodeToBeDeleted - - let parentNode = this.searchTree({ - element: params.tree, - matchingId: params.nodeToBeDeleted.parent.id - }); - let nodeToBeDeleted = this.searchTree({ - element: parentNode, - matchingId: params.nodeToBeDeleted.id - }); - - if(nodeToBeDeleted.children) { - _.forEach(nodeToBeDeleted.children, function(child) { - if(nodeToBeDeleted.isRoot) { - child.isRoot = true; - child.edgeType = "always"; - } - child.parent = parentNode; - parentNode.children.push(child); - }); - } - - _.forEach(parentNode.children, function(child, index) { - if(child.id === params.nodeToBeDeleted.id) { - parentNode.children.splice(index, 1); - return false; - } - }); - }, - addPlaceholderNode: function(params) { - // params.parent - // params.betweenTwoNodes - // params.tree - // params.id - - let placeholder = { - children: [], - c: "#D7D7D7", - id: params.id, - canDelete: true, - canEdit: false, - canAddTo: true, - placeholder: true, - isNew: true, - edited: false, - isRoot: (params.betweenTwoNodes) ? _.get(params, 'parent.source.isStartNode', false) : _.get(params, 'parent.isStartNode', false) - }; - - let parentNode = (params.betweenTwoNodes) ? this.searchTree({element: params.tree, matchingId: params.parent.source.id}) : this.searchTree({element: params.tree, matchingId: params.parent.id}); - let placeholderRef; - - if (params.betweenTwoNodes) { - _.forEach(parentNode.children, function(child, index) { - if (child.id === params.parent.target.id) { - child.isRoot = false; - placeholder.children.push(child); - parentNode.children[index] = placeholder; - placeholderRef = parentNode.children[index]; - child.parent = parentNode.children[index]; - return false; - } - }); - } else { - if (parentNode.children) { - parentNode.children.push(placeholder); - placeholderRef = parentNode.children[parentNode.children.length - 1]; - } else { - parentNode.children = [placeholder]; - placeholderRef = parentNode.children[0]; - } - } - - return placeholderRef; - }, - getSiblingConnectionTypes: function(params) { - // params.parentId - // params.childId - // params.tree - - let siblingConnectionTypes = {}; - - let parentNode = this.searchTree({ - element: params.tree, - matchingId: params.parentId - }); - - if(parentNode.children && parentNode.children.length > 0) { - // Loop across them and add the types as keys to siblingConnectionTypes - _.forEach(parentNode.children, function(child) { - if(child.id !== params.childId && !child.placeholder && child.edgeType) { - siblingConnectionTypes[child.edgeType] = true; - } - }); - } - - return Object.keys(siblingConnectionTypes); - }, - buildTree: function(params) { - //params.workflowNodes - - let deferred = $q.defer(); - - let _this = this; - - let treeData = { - data: { - id: 1, - canDelete: false, - canEdit: false, - canAddTo: true, - isStartNode: true, - unifiedJobTemplate: { - name: "Workflow Launch" - }, - children: [], - deletedNodes: [], - totalNodes: 0 - }, - nextIndex: 2 - }; - - let nodesArray = params.workflowNodes; - let nodesObj = {}; - let nonRootNodeIds = []; - let allNodeIds = []; - - // Determine which nodes are root nodes - _.forEach(nodesArray, function(node) { - nodesObj[node.id] = _.clone(node); - - allNodeIds.push(node.id); - - _.forEach(node.success_nodes, function(nodeId){ - nonRootNodeIds.push(nodeId); - }); - _.forEach(node.failure_nodes, function(nodeId){ - nonRootNodeIds.push(nodeId); - }); - _.forEach(node.always_nodes, function(nodeId){ - nonRootNodeIds.push(nodeId); - }); - }); - - let rootNodes = _.difference(allNodeIds, nonRootNodeIds); - - // Loop across the root nodes and re-build the tree - _.forEach(rootNodes, function(rootNodeId) { - let branch = _this.buildBranch({ - nodeId: rootNodeId, - edgeType: "always", - nodesObj: nodesObj, - isRoot: true, - treeData: treeData - }); - - treeData.data.children.push(branch); - }); - - deferred.resolve(treeData); - - return deferred.promise; - }, - buildBranch: function(params) { - // params.nodeId - // params.parentId - // params.edgeType - // params.nodesObj - // params.isRoot - // params.treeData - - let _this = this; - - let treeNode = { - children: [], - c: "#D7D7D7", - id: params.treeData.nextIndex, - nodeId: params.nodeId, - canDelete: true, - canEdit: true, - canAddTo: true, - placeholder: false, - edgeType: params.edgeType, - isNew: false, - edited: false, - originalEdge: params.edgeType, - originalNodeObj: _.clone(params.nodesObj[params.nodeId]), - promptValues: {}, - isRoot: params.isRoot ? params.isRoot : false - }; - - params.treeData.data.totalNodes++; - - params.treeData.nextIndex++; - - if(params.parentId) { - treeNode.originalParentId = params.parentId; - } - - if(params.nodesObj[params.nodeId].summary_fields) { - if(params.nodesObj[params.nodeId].summary_fields.job) { - treeNode.job = _.clone(params.nodesObj[params.nodeId].summary_fields.job); - } - - if(params.nodesObj[params.nodeId].summary_fields.unified_job_template) { - treeNode.unifiedJobTemplate = _.clone(params.nodesObj[params.nodeId].summary_fields.unified_job_template); - } - } - - // Loop across the success nodes and add them recursively - _.forEach(params.nodesObj[params.nodeId].success_nodes, function(successNodeId) { - treeNode.children.push(_this.buildBranch({ - nodeId: successNodeId, - parentId: params.nodeId, - edgeType: "success", - nodesObj: params.nodesObj, - treeData: params.treeData - })); - }); - - // failure nodes - _.forEach(params.nodesObj[params.nodeId].failure_nodes, function(failureNodesId) { - treeNode.children.push(_this.buildBranch({ - nodeId: failureNodesId, - parentId: params.nodeId, - edgeType: "failure", - nodesObj: params.nodesObj, - treeData: params.treeData - })); - }); - - // always nodes - _.forEach(params.nodesObj[params.nodeId].always_nodes, function(alwaysNodesId) { - treeNode.children.push(_this.buildBranch({ - nodeId: alwaysNodesId, - parentId: params.nodeId, - edgeType: "always", - nodesObj: params.nodesObj, - treeData: params.treeData - })); - }); - - return treeNode; - }, - updateStatusOfNode: function(params) { - // params.treeData - // params.nodeId - // params.status - - let matchingNode = this.searchTree({ - element: params.treeData.data, - matchingId: params.nodeId, - byNodeId: true - }); - - if(matchingNode) { - matchingNode.job = { - status: params.status, - id: params.unified_job_id - }; - } - - }, - }; -}]; diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index f40e8867d1..3b297cc073 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -1,10 +1,13 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', 'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange', - 'ParseVariableString', 'WorkflowService', 'count', '$state', 'i18n', - 'moment', '$filter', function(workflowData, workflowResultsService, + 'ParseVariableString', 'count', '$state', 'i18n', + 'moment', function(workflowData, workflowResultsService, workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, - ParseVariableString, WorkflowService, count, $state, i18n, moment, $filter) { + ParseVariableString, count, $state, i18n, moment) { var runTimeElapsedTimer = null; + let workflowMakerNodeIdCounter = 1; + let nodeIdToMakerIdMapping = {}; + let chartNodeIdToIndexMapping = {}; var getLinks = function() { var getLink = function(key) { @@ -113,11 +116,8 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', $scope.workflow_nodes = workflowNodes; $scope.workflowOptions = workflowDataOptions.actions.GET; $scope.labels = jobLabels; - $scope.count = count.val; $scope.showManualControls = false; - $scope.showKey = false; - $scope.toggleKey = () => $scope.showKey = !$scope.showKey; - $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; + $scope.readOnly = true; // Start elapsed time updater for job known to be running if ($scope.workflow.started !== null && $scope.workflow.status === 'running') { @@ -167,25 +167,96 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', $scope.varsTooltip= i18n._('Read only view of extra variables added to the workflow.'); $scope.varsLabel = i18n._('Extra Variables'); - // Click binding for the expand/collapse button on the standard out log $scope.stdoutFullScreen = false; - WorkflowService.buildTree({ - workflowNodes: workflowNodes - }).then(function(data){ - $scope.treeData = data; - - // TODO: I think that the workflow chart directive (and eventually d3) is meddling with - // this treeData object and removing the children object for some reason (?) - // This happens on occasion and I think is a race condition (?) - if(!$scope.treeData.data.children) { - $scope.treeData.data.children = []; + let nonRootNodeIds = []; + let allNodeIds = []; + let arrayOfLinksForChart = []; + let arrayOfNodesForChart = [ + { + index: 0, + id: workflowMakerNodeIdCounter, + isStartNode: true, + unifiedJobTemplate: { + name: "START" + }, + fixed: true, + x: 0, + y: 0 } + ]; - $scope.canAddWorkflowJobTemplate = false; + workflowMakerNodeIdCounter++; + // Assign each node an ID - 0 is reserved for the start node. We need to + // make sure that we have an ID on every node including new nodes so the + // ID returned by the api won't do + workflowNodes.forEach((node) => { + node.workflowMakerNodeId = workflowMakerNodeIdCounter; + const nodeObj = { + index: workflowMakerNodeIdCounter-1, + id: workflowMakerNodeIdCounter, + unifiedJobTemplate: node.summary_fields.unified_job_template + }; + if(node.summary_fields.job) { + nodeObj.job = node.summary_fields.job; + } + if(node.summary_fields.unified_job_template) { + nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; + } + arrayOfNodesForChart.push(nodeObj); + allNodeIds.push(node.id); + nodeIdToMakerIdMapping[node.id] = node.workflowMakerNodeId; + chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = workflowMakerNodeIdCounter-1; + workflowMakerNodeIdCounter++; }); + workflowNodes.forEach((node) => { + const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId]; + node.success_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "success" + }); + nonRootNodeIds.push(nodeId); + }); + node.failure_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "failure" + }); + nonRootNodeIds.push(nodeId); + }); + node.always_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "always" + }); + nonRootNodeIds.push(nodeId); + }); + }); + + let uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds)); + + let rootNodes = _.difference(allNodeIds, uniqueNonRootNodeIds); + + rootNodes.forEach((rootNodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[rootNodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[0], + target: arrayOfNodesForChart[targetIndex], + edgeType: "always" + }); + }); + + $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart }; + } $scope.toggleStdoutFullscreen = function() { @@ -285,12 +356,11 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer); } - WorkflowService.updateStatusOfNode({ - treeData: $scope.treeData, - nodeId: data.workflow_node_id, - status: data.status, - unified_job_id: data.unified_job_id - }); + $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[data.workflow_node_id]]].job = { + id: data.unified_job_id, + status: data.status + }; + $scope.workflow_nodes.forEach(node => { if(parseInt(node.id) === parseInt(data.workflow_node_id)){ @@ -300,8 +370,6 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', } }); - $scope.count = workflowResultsService - .getCounts($scope.workflow_nodes); $scope.$broadcast("refreshWorkflowChart"); } getLabelsAndTooltips(); diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 48dacb6e3f..39a9116fdf 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -363,7 +363,14 @@ - + + From 61fb3eb3909b389d853caf3bafb982f2c17fd0b2 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 8 Nov 2018 11:32:05 -0500 Subject: [PATCH 35/99] First pass at implementing better node placement in the workflow graph --- .../workflows/workflow-chart/main.js | 4 +- .../workflow-chart.directive.js | 13 +- .../workflow-chart/workflow-chart.service.js | 144 ++++++++++++++++++ .../workflow-maker.controller.js | 131 ++++++---------- .../workflow-results.controller.js | 98 ++---------- 5 files changed, 207 insertions(+), 183 deletions(-) create mode 100644 awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/main.js b/awx/ui/client/src/templates/workflows/workflow-chart/main.js index 2b1851a972..12379f4b8f 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/main.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/main.js @@ -5,7 +5,9 @@ *************************************************/ import workflowChart from './workflow-chart.directive'; +import workflowChartService from './workflow-chart.service'; export default angular.module('workflowChart', []) - .directive('workflowChart', workflowChart); + .directive('workflowChart', workflowChart) + .service('WorkflowChartService', workflowChartService); diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index cb4b392e7c..eca1f5b546 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -56,7 +56,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function init() { force = d3.layout.force() .gravity(0) - .charge(-60) + .charge(-300) .linkDistance(300) .size([windowHeight, windowWidth]); @@ -1003,6 +1003,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }); + // TODO: this // if(scope.treeState.arrayOfNodesForChart && scope.treeState.arrayOfNodesForChart > 1 && !graphLoaded) { // zoomToFitChart(); // } @@ -1010,14 +1011,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge graphLoaded = true; // This will make sure that all the link elements appear before the nodes in the dom + // TODO: i don't think this is working... svgGroup.selectAll(".WorkflowChart-node").order(); - let tick = (e) => { - var k = 6 * e.alpha; - - // TODO: replace hard-coded 60 here + let tick = () => { linkLines - .each(function(d) { d.source.y -= k; d.target.y += k; }) + .each(function(d) { + d.target.y = scope.treeState.depthMap[d.target.id] * 300; + }) .attr("x1", function(d) { return d.target.y; }) .attr("y1", function(d) { return d.target.x + (nodeH/2); }) .attr("x2", function(d) { return d.source.index === 0 ? (scope.mode === 'details' ? d.source.y + 25 : d.source.y + 60) : (d.source.y + nodeW); }) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js new file mode 100644 index 0000000000..4c51ecc1d1 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -0,0 +1,144 @@ +export default [function(){ + return { + generateDepthMap: (arrayOfLinks) => { + let depthMap = {}; + let nodesWithChildren = {}; + + let walkBranch = (nodeId, depth) => { + depthMap[nodeId] = depthMap[nodeId] ? (depth > depthMap[nodeId] ? depth : depthMap[nodeId]) : depth; + if (nodesWithChildren[nodeId]) { + _.forEach(nodesWithChildren[nodeId].children, (childNodeId) => { + walkBranch(childNodeId, depth+1); + }); + } + }; + + let rootNodeIds = []; + arrayOfLinks.forEach(link => { + // link.source.index of 0 is our artificial start node + if (link.source.index !== 0) { + if (!nodesWithChildren[link.source.id]) { + nodesWithChildren[link.source.id] = { + children: [] + }; + } + + nodesWithChildren[link.source.id].children.push(link.target.id); + } else { + // Store the fact that might be a root node + rootNodeIds.push(link.target.id); + } + }); + + _.forEach(rootNodeIds, function(rootNodeId) { + walkBranch(rootNodeId, 1); + depthMap[rootNodeId] = 1; + }); + + return depthMap; + }, + generateArraysOfNodesAndLinks: function(allNodes) { + let nonRootNodeIds = []; + let allNodeIds = []; + let arrayOfLinksForChart = []; + let nodeIdToChartNodeIdMapping = {}; + let chartNodeIdToIndexMapping = {}; + let nodeRef = {}; + let nodeIdCounter = 1; + let arrayOfNodesForChart = [ + { + index: 0, + id: nodeIdCounter, + isStartNode: true, + unifiedJobTemplate: { + name: "START" + }, + fixed: true, + x: 0, + y: 0 + } + ]; + nodeIdCounter++; + // Assign each node an ID - 0 is reserved for the start node. We need to + // make sure that we have an ID on every node including new nodes so the + // ID returned by the api won't do + allNodes.forEach((node) => { + node.workflowMakerNodeId = nodeIdCounter; + nodeRef[nodeIdCounter] = { + originalNodeObject: node + }; + + const nodeObj = { + index: nodeIdCounter-1, + id: nodeIdCounter + }; + + if(node.summary_fields.job) { + nodeObj.job = node.summary_fields.job; + } + if(node.summary_fields.unified_job_template) { + nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; + } + + arrayOfNodesForChart.push(nodeObj); + allNodeIds.push(node.id); + nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId; + chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter-1; + nodeIdCounter++; + }); + + allNodes.forEach((node) => { + const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId]; + node.success_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "success" + }); + nonRootNodeIds.push(nodeId); + }); + node.failure_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "failure" + }); + nonRootNodeIds.push(nodeId); + }); + node.always_nodes.forEach((nodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[sourceIndex], + target: arrayOfNodesForChart[targetIndex], + edgeType: "always" + }); + nonRootNodeIds.push(nodeId); + }); + }); + + let uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds)); + + let rootNodes = _.difference(allNodeIds, uniqueNonRootNodeIds); + + rootNodes.forEach((rootNodeId) => { + const targetIndex = chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]]; + arrayOfLinksForChart.push({ + source: arrayOfNodesForChart[0], + target: arrayOfNodesForChart[targetIndex], + edgeType: "always" + }); + }); + + return { + arrayOfNodesForChart, + arrayOfLinksForChart, + chartNodeIdToIndexMapping, + nodeIdToChartNodeIdMapping, + nodeRef, + workflowMakerNodeIdCounter: nodeIdCounter + }; + } + }; +}]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 42b159eebc..0bb164ea95 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -6,10 +6,10 @@ export default ['$scope', 'TemplatesService', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', - 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', + 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', 'WorkflowChartService', function ($scope, TemplatesService, ProcessErrors, CreateSelect2, $q, JobTemplate, - Empty, PromptService, Rest, TemplatesStrings) { + Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService) { $scope.strings = TemplatesStrings; // TODO: I don't think this needs to be on scope but changing it will require changes to @@ -19,13 +19,10 @@ export default ['$scope', 'TemplatesService', let credentialRequests = []; let deletedNodeIds = []; let workflowMakerNodeIdCounter = 1; - let nodeIdToMakerIdMapping = {}; + let nodeIdToChartNodeIdMapping = {}; let chartNodeIdToIndexMapping = {}; let nodeRef = {}; - // TODO: fix this - $scope.totalNodes = 0; - $scope.showKey = false; $scope.toggleKey = () => $scope.showKey = !$scope.showKey; $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; @@ -108,7 +105,7 @@ export default ['$scope', 'TemplatesService', }).then(({data}) => { nodeRef[workflowMakerNodeId].originalNodeObject = data; // TODO: do we need this? - nodeIdToMakerIdMapping[data.id] = parseInt(workflowMakerNodeId); + nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); // if (_.get(params, 'node.promptData.launchConf.ask_credential_on_launch')) { // // This finds the credentials that were selected in the prompt but don't occur // // in the template defaults @@ -222,8 +219,8 @@ export default ['$scope', 'TemplatesService', Object.keys(linkMap).map((sourceNodeId) => { Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { - const foo = nodeIdToMakerIdMapping[sourceNodeId]; - const bar = nodeIdToMakerIdMapping[targetNodeId]; + const foo = nodeIdToChartNodeIdMapping[sourceNodeId]; + const bar = nodeIdToChartNodeIdMapping[targetNodeId]; switch(linkMap[sourceNodeId][targetNodeId]) { case "success": if ( @@ -340,6 +337,8 @@ export default ['$scope', 'TemplatesService', workflowMakerNodeIdCounter++; + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.$broadcast("refreshWorkflowChart"); $scope.formState.showNodeForm = true; @@ -381,6 +380,8 @@ export default ['$scope', 'TemplatesService', workflowMakerNodeIdCounter++; + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.$broadcast("refreshWorkflowChart"); $scope.formState.showNodeForm = true; @@ -476,6 +477,8 @@ export default ['$scope', 'TemplatesService', chartNodeIdToIndexMapping[key]--; } } + + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); } else if ($scope.nodeConfig.mode === "edit") { $scope.treeState.arrayOfNodesForChart.map( (node) => { if (node.index === $scope.nodeConfig.nodeId) { @@ -562,6 +565,7 @@ export default ['$scope', 'TemplatesService', // User is going from editing one link to editing another if ($scope.linkConfig.mode === "add") { $scope.treeState.arrayOfLinksForChart.splice($scope.treeState.arrayOfLinksForChart.length-1, 1); + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); } $scope.treeState.arrayOfLinksForChart.forEach((link) => { link.isLinkBeingEdited = false; @@ -575,7 +579,6 @@ export default ['$scope', 'TemplatesService', }; $scope.selectNodeForLinking = (node) => { - // start here if ($scope.linkConfig) { // This is the second node selected $scope.linkConfig.child = { @@ -595,6 +598,14 @@ export default ['$scope', 'TemplatesService', isLinkBeingEdited: true }); + $scope.treeState.arrayOfLinksForChart.forEach((link, index) => { + if (link.source.id === 1 && link.target.id === node.id) { + $scope.treeState.arrayOfLinksForChart.splice(index, 1); + } + }); + + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.treeState.isLinkMode = false; } else { // This is the first node selected @@ -681,6 +692,8 @@ export default ['$scope', 'TemplatesService', } } + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.formState.showLinkForm = false; $scope.linkConfig = null; $scope.$broadcast("refreshWorkflowChart"); @@ -689,6 +702,21 @@ export default ['$scope', 'TemplatesService', $scope.cancelLinkForm = () => { if ($scope.linkConfig.mode === "add" && $scope.linkConfig.child) { $scope.treeState.arrayOfLinksForChart.splice($scope.treeState.arrayOfLinksForChart.length-1, 1); + let targetIsOrphaned = true; + $scope.treeState.arrayOfLinksForChart.forEach((link) => { + if (link.target.id === $scope.linkConfig.child.id) { + targetIsOrphaned = false; + } + }); + if (targetIsOrphaned) { + // Link it to the start node + $scope.treeState.arrayOfLinksForChart.push({ + source: $scope.treeState.arrayOfNodesForChart[0], + target: $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.child.id]], + edgeType: "always" + }); + } + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); } $scope.treeState.addLinkSource = null; $scope.treeState.isLinkMode = false; @@ -779,6 +807,8 @@ export default ['$scope', 'TemplatesService', } } + $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.nodeToBeDeleted = null; $scope.deleteOverlayVisible = false; @@ -832,87 +862,14 @@ export default ['$scope', 'TemplatesService', page++; getNodes(); } else { - let nonRootNodeIds = []; - let allNodeIds = []; let arrayOfLinksForChart = []; - let arrayOfNodesForChart = [ - { - index: 0, - id: workflowMakerNodeIdCounter, - isStartNode: true, - unifiedJobTemplate: { - name: "START" - }, - fixed: true, - x: 0, - y: 0 - } - ]; - workflowMakerNodeIdCounter++; - // Assign each node an ID - 0 is reserved for the start node. We need to - // make sure that we have an ID on every node including new nodes so the - // ID returned by the api won't do - allNodes.forEach((node) => { - node.workflowMakerNodeId = workflowMakerNodeIdCounter; - nodeRef[workflowMakerNodeIdCounter] = { - originalNodeObject: node - }; - arrayOfNodesForChart.push({ - index: workflowMakerNodeIdCounter-1, - id: workflowMakerNodeIdCounter, - unifiedJobTemplate: node.summary_fields.unified_job_template - }); - allNodeIds.push(node.id); - nodeIdToMakerIdMapping[node.id] = node.workflowMakerNodeId; - chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = workflowMakerNodeIdCounter-1; - workflowMakerNodeIdCounter++; - }); + let arrayOfNodesForChart = []; - allNodes.forEach((node) => { - const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId]; - node.success_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "success" - }); - nonRootNodeIds.push(nodeId); - }); - node.failure_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "failure" - }); - nonRootNodeIds.push(nodeId); - }); - node.always_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "always" - }); - nonRootNodeIds.push(nodeId); - }); - }); + ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); - let uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds)); + let depthMap = WorkflowChartService.generateDepthMap(arrayOfLinksForChart); - let rootNodes = _.difference(allNodeIds, uniqueNonRootNodeIds); - - rootNodes.forEach((rootNodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[rootNodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[0], - target: arrayOfNodesForChart[targetIndex], - edgeType: "always" - }); - }); - - $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart }; + $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; } }, function ({ data, status, config }) { ProcessErrors($scope, data, status, null, { diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 3b297cc073..2e30bda4be 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -1,12 +1,12 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', 'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange', - 'ParseVariableString', 'count', '$state', 'i18n', + 'ParseVariableString', 'count', '$state', 'i18n', 'WorkflowChartService', 'moment', function(workflowData, workflowResultsService, workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, - ParseVariableString, count, $state, i18n, moment) { + ParseVariableString, count, $state, i18n, WorkflowChartService, + moment) { var runTimeElapsedTimer = null; - let workflowMakerNodeIdCounter = 1; - let nodeIdToMakerIdMapping = {}; + let nodeIdToChartNodeIdMapping = {}; let chartNodeIdToIndexMapping = {}; var getLinks = function() { @@ -170,93 +170,14 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', // Click binding for the expand/collapse button on the standard out log $scope.stdoutFullScreen = false; - let nonRootNodeIds = []; - let allNodeIds = []; let arrayOfLinksForChart = []; - let arrayOfNodesForChart = [ - { - index: 0, - id: workflowMakerNodeIdCounter, - isStartNode: true, - unifiedJobTemplate: { - name: "START" - }, - fixed: true, - x: 0, - y: 0 - } - ]; + let arrayOfNodesForChart = []; - workflowMakerNodeIdCounter++; - // Assign each node an ID - 0 is reserved for the start node. We need to - // make sure that we have an ID on every node including new nodes so the - // ID returned by the api won't do - workflowNodes.forEach((node) => { - node.workflowMakerNodeId = workflowMakerNodeIdCounter; - const nodeObj = { - index: workflowMakerNodeIdCounter-1, - id: workflowMakerNodeIdCounter, - unifiedJobTemplate: node.summary_fields.unified_job_template - }; - if(node.summary_fields.job) { - nodeObj.job = node.summary_fields.job; - } - if(node.summary_fields.unified_job_template) { - nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; - } - arrayOfNodesForChart.push(nodeObj); - allNodeIds.push(node.id); - nodeIdToMakerIdMapping[node.id] = node.workflowMakerNodeId; - chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = workflowMakerNodeIdCounter-1; - workflowMakerNodeIdCounter++; - }); + ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes)); - workflowNodes.forEach((node) => { - const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId]; - node.success_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "success" - }); - nonRootNodeIds.push(nodeId); - }); - node.failure_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "failure" - }); - nonRootNodeIds.push(nodeId); - }); - node.always_nodes.forEach((nodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[nodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[sourceIndex], - target: arrayOfNodesForChart[targetIndex], - edgeType: "always" - }); - nonRootNodeIds.push(nodeId); - }); - }); - - let uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds)); - - let rootNodes = _.difference(allNodeIds, uniqueNonRootNodeIds); - - rootNodes.forEach((rootNodeId) => { - const targetIndex = chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[rootNodeId]]; - arrayOfLinksForChart.push({ - source: arrayOfNodesForChart[0], - target: arrayOfNodesForChart[targetIndex], - edgeType: "always" - }); - }); - - $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart }; + let depthMap = WorkflowChartService.generateDepthMap(arrayOfLinksForChart); + $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; } $scope.toggleStdoutFullscreen = function() { @@ -356,12 +277,11 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer); } - $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[nodeIdToMakerIdMapping[data.workflow_node_id]]].job = { + $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[data.workflow_node_id]]].job = { id: data.unified_job_id, status: data.status }; - $scope.workflow_nodes.forEach(node => { if(parseInt(node.id) === parseInt(data.workflow_node_id)){ node.summary_fields.job = { From 05f4d94db234758853bd28a233c604e15b597475 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 9 Nov 2018 09:15:42 -0500 Subject: [PATCH 36/99] Fixed serveral bugs including credential prompting. Added logic to bring links/nodes to the forefront when you hover over them in case there's some overlap --- .../workflow-chart.directive.js | 134 +++++---- .../workflow-maker.controller.js | 280 +++++++++--------- .../workflow-maker.partial.html | 4 +- .../workflow-results.controller.js | 4 +- .../workflow-results.partial.html | 2 +- 5 files changed, 215 insertions(+), 209 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index eca1f5b546..e5bfcc2251 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -9,7 +9,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return { scope: { - treeState: '=', + graphState: '=', readOnly: '<', addNodeWithoutChild: '&', addNodeWithChild: '&', @@ -55,6 +55,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function init() { force = d3.layout.force() + // .gravity(0) + // .linkStrength(2) + // .friction(0.4) + // .charge(-4000) + // .linkDistance(300) .gravity(0) .charge(-300) .linkDistance(300) @@ -206,7 +211,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function update() { if(scope.dimensionsSet) { let links = svgGroup.selectAll(".WorkflowChart-link") - .data(scope.treeState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); + .data(scope.graphState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); // Remove any stale links links.exit().remove(); @@ -217,7 +222,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge baseSvg.selectAll(".WorkflowChart-linkPath") .attr("class", function(d) { - return (d.source.isNodeBeingAdded || d.target.isNodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; + return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) .attr('stroke', function(d) { let edgeType = d.edgeType; @@ -241,7 +246,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) .attr("class", function(d) { let linkClasses = ["WorkflowChart-linkOverlay"]; - if (d.isLinkBeingEdited) { + if ( + scope.graphState.linkBeingEdited && + d.source.id === scope.graphState.linkBeingEdited.source && + d.target.id === scope.graphState.linkBeingEdited.target + ) { linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); @@ -249,10 +258,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); // Add any new links @@ -263,7 +272,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge linkEnter.append("polygon", "g") .attr("class", function(d) { let linkClasses = ["WorkflowChart-linkOverlay"]; - if (d.isLinkBeingEdited) { + if ( + scope.graphState.linkBeingEdited && + d.source.id === scope.graphState.linkBeingEdited.source && + d.target.id === scope.graphState.linkBeingEdited.target + ) { linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); @@ -271,8 +284,9 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) .call(edit_link) .on("mouseover", function(d) { - if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details') { - d3.select("#link-" + d.source.id + "-" + d.target.id) + if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-linkHovering", true); let xPos, yPos, arrowClass; @@ -314,7 +328,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .on("mouseout", function(d){ - if(!d.source.isStartNode && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + if(!d.source.isStartNode && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-linkHovering", false); } @@ -324,11 +339,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // Add entering links in the parent’s old position. linkEnter.append("line") .attr("class", function(d) { - return (d.source.isNodeBeingAdded || d.target.isNodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; + return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) .call(edit_link) .on("mouseenter", function(d) { - if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-linkHovering", true); @@ -370,7 +386,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }) .on("mouseleave", function(d){ - if(!d.source.isStartNode && !d.target.isNodeBeingAdded && scope.mode !== 'details') { + if(!d.source.isStartNode && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-linkHovering", false); } @@ -398,13 +415,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") - .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(add_node_with_child) .on("mouseover", function(d) { + $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ + $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-addHovering", false); }); @@ -416,13 +435,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return (scope.treeState.isLinkMode || d.source.isNodeBeingAdded || d.target.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(add_node_with_child) .on("mouseover", function(d) { + $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-addHovering", true); }) .on("mouseout", function(d){ + $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-addHovering", false); }); @@ -435,29 +456,29 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let linkAddBetweenIcon = svgGroup.selectAll(".WorkflowChart-betweenNodesIcon"); let nodes = svgGroup.selectAll('.WorkflowChart-node') - .data(scope.treeState.arrayOfNodesForChart, function(d) { return d.id; }); + .data(scope.graphState.arrayOfNodesForChart, function(d) { return d.id; }); // Remove any stale nodes nodes.exit().remove(); // Update existing nodes baseSvg.selectAll(".WorkflowChart-nodeAddCircle") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeAddIcon") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-linkCircle") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeLinkIcon") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-nodeRemoveIcon") - .style("display", function(d) { return scope.treeState.isLinkMode || d.isNodeBeingAdded || scope.readOnly ? "none" : null; }); + .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-rect") .attr('stroke', function(d) { @@ -477,7 +498,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }) .attr("class", function(d) { - let classString = d.isNodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; + let classString = d.id === scope.graphState.nodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; return classString; }); @@ -561,17 +582,17 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); baseSvg.selectAll(".WorkflowChart-deletedText") - .style("display", function(d){ return d.unifiedJobTemplate || d.isNodeBeingAdded ? "none" : null; }); + .style("display", function(d){ return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); baseSvg.selectAll(".WorkflowChart-activeNode") - .style("display", function(d) { return d.isNodeBeingEdited ? null : "none"; }); + .style("display", function(d) { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); baseSvg.selectAll(".WorkflowChart-elapsed") .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); baseSvg.selectAll(".WorkflowChart-addLinkCircle") - .attr("fill", function(d) { return scope.treeState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) - .style("display", function(d) { return scope.treeState.isLinkMode && !d.isInvalidLinkTarget ? null : "none"; }); + .attr("fill", function(d) { return scope.graphState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) + .style("display", function(d) { return scope.graphState.isLinkMode && !d.isInvalidLinkTarget ? null : "none"; }); // Add new nodes const nodeEnter = nodes @@ -619,7 +640,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cx", nodeW) .attr("r", 8) .attr("class", "WorkflowChart-addLinkCircle") - .style("display", function() { return scope.treeState.isLinkMode ? null : "none"; }); + .style("display", function() { return scope.graphState.isLinkMode ? null : "none"; }); thisNode.append("rect") .attr("width", nodeW) .attr("height", nodeH) @@ -643,7 +664,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .attr('stroke-width', "2px") .attr("class", function(d) { - let classString = d.isNodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; + let classString = d.id === scope.graphState.nodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; classString += !_.get(d, 'unifiedJobTemplate.name') ? " WorkflowChart-dashedNode" : ""; return classString; }); @@ -651,7 +672,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge thisNode.append("path") .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) .attr("class", "WorkflowChart-activeNode") - .style("display", function(d) { return d.isNodeBeingEdited ? null : "none"; }); + .style("display", function(d) { return d.id === scope.graphState.nodeBeingEdited ? null : "none"; }); thisNode.append("text") .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) @@ -673,7 +694,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .html(function () { return `${TemplatesStrings.get('workflow_maker.DELETED')}`; }) - .style("display", function(d) { return d.unifiedJobTemplate || d.isNodeBeingAdded ? "none" : null; }); + .style("display", function(d) { return d.unifiedJobTemplate || d.id === scope.graphState.nodeBeingAdded ? "none" : null; }); thisNode.append("circle") .attr("cy", nodeH) @@ -738,6 +759,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .call(node_click) .on("mouseover", function(d) { if(!d.isStartNode) { + $(`#node-${d.id}`).appendTo(`#aw-workflow-chart-g`); let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; if(resourceName && resourceName.length > maxNodeTextLength) { // When the graph is initially rendered all the links come after the nodes (when you look at the dom). @@ -773,8 +795,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); } - if (scope.treeState.isLinkMode && !d.isInvalidLinkTarget && scope.treeState.addLinkSource !== d.id) { - let sourceNode = d3.select(`#node-${scope.treeState.addLinkSource}`); + if (scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { + let sourceNode = d3.select(`#node-${scope.graphState.addLinkSource}`); const sourceNodeX = d3.transform(sourceNode.attr("transform")).translate[0]; const sourceNodeY = d3.transform(sourceNode.attr("transform")).translate[1]; @@ -821,7 +843,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cx", nodeW) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-nodeAddCircle") - .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_node_without_child) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -843,7 +865,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_node_without_child) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -863,7 +885,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cy", nodeH/2) .attr("r", 10) .attr("class", "WorkflowChart-linkCircle") - .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_link) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -877,8 +899,6 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge d3.select("#node-" + d.id + "-link") .classed("WorkflowChart-linkButtonHovering", false); }); - // TODO: clean up the placement of this icon... this works but it's not - // clean thisNode.append("foreignObject") .attr("x", nodeW - 6) .attr("y", nodeH/2 - 9) @@ -887,7 +907,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return ``; }) .attr("class", "WorkflowChart-nodeLinkIcon") - .style("display", function(d) { return d.isNodeBeingAdded || scope.readOnly ? "none" : null; }) + .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) .call(add_link) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -907,7 +927,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cy", nodeH) .attr("r", 10) .attr("class", "WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return (d.isStartNode || d.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.isStartNode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -929,7 +949,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return (d.isStartNode || d.isNodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.isStartNode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -1004,7 +1024,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); // TODO: this - // if(scope.treeState.arrayOfNodesForChart && scope.treeState.arrayOfNodesForChart > 1 && !graphLoaded) { + // if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart > 1 && !graphLoaded) { // zoomToFitChart(); // } @@ -1017,7 +1037,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let tick = () => { linkLines .each(function(d) { - d.target.y = scope.treeState.depthMap[d.target.id] * 300; + d.target.y = scope.graphState.depthMap[d.target.id] * 300; }) .attr("x1", function(d) { return d.target.y; }) .attr("y1", function(d) { return d.target.x + (nodeH/2); }) @@ -1068,8 +1088,8 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }; force - .nodes(scope.treeState.arrayOfNodesForChart) - .links(scope.treeState.arrayOfLinksForChart) + .nodes(scope.graphState.arrayOfNodesForChart) + .links(scope.graphState.arrayOfLinksForChart) .on("tick", tick) .start(); } @@ -1086,7 +1106,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function add_node_without_child() { this.on("click", function(d) { - if(!scope.readOnly && !scope.treeState.isLinkMode) { + if(!scope.readOnly && !scope.graphState.isLinkMode) { scope.addNodeWithoutChild({ parent: d }); @@ -1096,7 +1116,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function add_node_with_child() { this.on("click", function(d) { - if(!scope.readOnly && !scope.treeState.isLinkMode) { + if(!scope.readOnly && !scope.graphState.isLinkMode) { scope.addNodeWithChild({ link: d }); @@ -1106,7 +1126,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function remove_node() { this.on("click", function(d) { - if(!d.isStartNode && !scope.readOnly && !scope.treeState.isLinkMode) { + if(!d.isStartNode && !scope.readOnly && !scope.graphState.isLinkMode) { scope.deleteNode({ nodeToDelete: d }); @@ -1116,13 +1136,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function node_click() { this.on("click", function(d) { - if(!d.isStartNode && !scope.readOnly){ - if(scope.treeState.isLinkMode && !d.isInvalidLinkTarget) { + if(d.id !== scope.graphState.nodeBeingAdded && !scope.readOnly){ + if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget) { $('.WorkflowChart-potentialLink').remove(); scope.selectNodeForLinking({ nodeToStartLink: d }); - } else if(!scope.treeState.isLinkMode) { + } else if(!scope.graphState.isLinkMode) { scope.editNode({ nodeToEdit: d }); @@ -1134,7 +1154,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function edit_link() { this.on("click", function(d) { - if(!scope.treeState.isLinkMode && !d.source.isStartNode && !d.source.isNodeBeingAdded && !d.target.isNodeBeingAdded && scope.mode !== 'details'){ + if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details'){ scope.editLink({ linkToEdit: d }); @@ -1144,7 +1164,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function add_link() { this.on("click", function(d) { - if (!scope.readOnly && !scope.treeState.isLinkMode) { + if (!scope.readOnly && !scope.graphState.isLinkMode) { scope.selectNodeForLinking({ nodeToStartLink: d }); @@ -1198,7 +1218,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } scope.$on('refreshWorkflowChart', function(){ - if(scope.treeState) { + if(scope.graphState) { update(); } }); @@ -1219,12 +1239,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge zoomToFitChart(); }); - let clearWatchTreeState = scope.$watch('treeState.arrayOfNodesForChart', function(newVal) { + let clearWatchgraphState = scope.$watch('graphState.arrayOfNodesForChart', function(newVal) { if(newVal) { - // scope.treeState.arrayOfNodesForChart + // scope.graphState.arrayOfNodesForChart update(); - clearWatchTreeState(); + clearWatchgraphState(); } }); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 0bb164ea95..824527bcbb 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -12,11 +12,8 @@ export default ['$scope', 'TemplatesService', Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService) { $scope.strings = TemplatesStrings; - // TODO: I don't think this needs to be on scope but changing it will require changes to - // all the prompt places $scope.preventCredsWithPasswords = true; - let credentialRequests = []; let deletedNodeIds = []; let workflowMakerNodeIdCounter = 1; let nodeIdToChartNodeIdMapping = {}; @@ -87,15 +84,16 @@ export default ['$scope', 'TemplatesService', $scope.closeWorkflowMaker = function() { // Revert the data to the master which was created when the dialog was opened - $scope.treeState.nodeTree = angular.copy($scope.treeStateMaster); + $scope.graphState.nodeTree = angular.copy($scope.graphStateMaster); $scope.closeDialog(); }; $scope.saveWorkflowMaker = function () { - if ($scope.treeState.arrayOfNodesForChart.length > 1) { + if ($scope.graphState.arrayOfNodesForChart.length > 1) { let addPromises = []; let editPromises = []; + let credentialsToPost = []; Object.keys(nodeRef).map((workflowMakerNodeId) => { if (nodeRef[workflowMakerNodeId].isNew) { @@ -104,27 +102,26 @@ export default ['$scope', 'TemplatesService', data: buildSendableNodeData(nodeRef[workflowMakerNodeId]) }).then(({data}) => { nodeRef[workflowMakerNodeId].originalNodeObject = data; - // TODO: do we need this? nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); - // if (_.get(params, 'node.promptData.launchConf.ask_credential_on_launch')) { - // // This finds the credentials that were selected in the prompt but don't occur - // // in the template defaults - // let credentialsToPost = params.node.promptData.prompts.credentials.value.filter(function (credFromPrompt) { - // let defaultCreds = _.get(params, 'node.promptData.launchConf.defaults.credentials', []); - // return !defaultCreds.some(function (defaultCred) { - // return credFromPrompt.id === defaultCred.id; - // }); - // }); - // - // credentialsToPost.forEach((credentialToPost) => { - // credentialRequests.push({ - // id: data.data.id, - // data: { - // id: credentialToPost.id - // } - // }); - // }); - // } + if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { + // This finds the credentials that were selected in the prompt but don't occur + // in the template defaults + let credentialIdsToPost = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter(function (credFromPrompt) { + let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); + return !defaultCreds.some(function (defaultCred) { + return credFromPrompt.id === defaultCred.id; + }); + }); + + credentialIdsToPost.forEach((credentialToPost) => { + credentialsToPost.push({ + id: data.id, + data: { + id: credentialToPost.id + } + }); + }); + } })); } else if (nodeRef[workflowMakerNodeId].isEdited) { editPromises.push(TemplatesService.editWorkflowNode({ @@ -146,9 +143,9 @@ export default ['$scope', 'TemplatesService', let linkMap = {}; // Build a link map for easy access - $scope.treeState.arrayOfLinksForChart.forEach(link => { - // link.source.index of 0 is our artificial start node - if (link.source.index !== 0) { + $scope.graphState.arrayOfLinksForChart.forEach(link => { + // link.source.id of 1 is our artificial start node + if (link.source.id !== 1) { const sourceNodeId = nodeRef[link.source.id].originalNodeObject.id; const targetNodeId = nodeRef[link.target.id].originalNodeObject.id; if (!linkMap[sourceNodeId]) { @@ -219,13 +216,13 @@ export default ['$scope', 'TemplatesService', Object.keys(linkMap).map((sourceNodeId) => { Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { - const foo = nodeIdToChartNodeIdMapping[sourceNodeId]; - const bar = nodeIdToChartNodeIdMapping[targetNodeId]; + const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; + const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; switch(linkMap[sourceNodeId][targetNodeId]) { case "success": if ( - !nodeRef[foo].originalNodeObject.success_nodes || - !nodeRef[foo].originalNodeObject.success_nodes.includes(nodeRef[bar].id) + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -238,8 +235,8 @@ export default ['$scope', 'TemplatesService', break; case "failure": if ( - !nodeRef[foo].originalNodeObject.failure_nodes || - !nodeRef[foo].originalNodeObject.failure_nodes.includes(nodeRef[bar].id) + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -252,8 +249,8 @@ export default ['$scope', 'TemplatesService', break; case "always": if ( - !nodeRef[foo].originalNodeObject.always_nodes || - !nodeRef[foo].originalNodeObject.always_nodes.includes(nodeRef[bar].id) + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -270,9 +267,7 @@ export default ['$scope', 'TemplatesService', $q.all(disassociatePromises) .then(function () { - - // TODO: don't forget about this.... - let credentialPromises = credentialRequests.map(function (request) { + let credentialPromises = credentialsToPost.map(function (request) { return TemplatesService.postWorkflowNodeCredential({ id: request.id, data: request.data @@ -291,8 +286,6 @@ export default ['$scope', 'TemplatesService', }); }); - // TODO: handle the case where the user deletes all the nodes - } else { let deletePromises = deletedNodeIds.map(function (nodeId) { @@ -314,18 +307,19 @@ export default ['$scope', 'TemplatesService', $scope.cancelNodeForm(); } - $scope.treeState.arrayOfNodesForChart.push({ - index: $scope.treeState.arrayOfNodesForChart.length, + $scope.graphState.arrayOfNodesForChart.push({ + index: $scope.graphState.arrayOfNodesForChart.length, id: workflowMakerNodeIdCounter, - isNodeBeingAdded: true, unifiedJobTemplate: null }); - chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = $scope.treeState.arrayOfNodesForChart.length - 1; + $scope.graphState.nodeBeingAdded = workflowMakerNodeIdCounter; - $scope.treeState.arrayOfLinksForChart.push({ - source: $scope.treeState.arrayOfNodesForChart[parent.index], - target: $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]], + chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = $scope.graphState.arrayOfNodesForChart.length - 1; + + $scope.graphState.arrayOfLinksForChart.push({ + source: $scope.graphState.arrayOfNodesForChart[parent.index], + target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]], edgeType: "placeholder" }); @@ -337,7 +331,7 @@ export default ['$scope', 'TemplatesService', workflowMakerNodeIdCounter++; - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); $scope.$broadcast("refreshWorkflowChart"); @@ -349,38 +343,39 @@ export default ['$scope', 'TemplatesService', $scope.cancelNodeForm(); } - $scope.treeState.arrayOfNodesForChart.push({ - index: $scope.treeState.arrayOfNodesForChart.length, + $scope.graphState.arrayOfNodesForChart.push({ + index: $scope.graphState.arrayOfNodesForChart.length, id: workflowMakerNodeIdCounter, - isNodeBeingAdded: true, unifiedJobTemplate: null }); - chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = $scope.treeState.arrayOfNodesForChart.length - 1; + $scope.graphState.nodeBeingAdded = workflowMakerNodeIdCounter; - $scope.treeState.arrayOfLinksForChart.push({ - source: $scope.treeState.arrayOfNodesForChart[link.source.index], - target: $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]], + chartNodeIdToIndexMapping[workflowMakerNodeIdCounter] = $scope.graphState.arrayOfNodesForChart.length - 1; + + $scope.graphState.arrayOfLinksForChart.push({ + source: $scope.graphState.arrayOfNodesForChart[link.source.index], + target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]], edgeType: "placeholder" }); $scope.nodeConfig = { mode: "add", nodeId: workflowMakerNodeIdCounter, - newNodeIsRoot: link.source.index === 0 + newNodeIsRoot: link.source.id === 1 }; // Search for the link that used to exist between source and target and shift it to // go from our new node to the target - $scope.treeState.arrayOfLinksForChart.forEach((foo) => { + $scope.graphState.arrayOfLinksForChart.forEach((foo) => { if (foo.source.id === link.source.id && foo.target.id === link.target.id) { - foo.source = $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]]; + foo.source = $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]]; } }); workflowMakerNodeIdCounter++; - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); $scope.$broadcast("refreshWorkflowChart"); @@ -391,17 +386,16 @@ export default ['$scope', 'TemplatesService', const nodeIndex = chartNodeIdToIndexMapping[$scope.nodeConfig.nodeId]; if ($scope.nodeConfig.mode === "add") { if (selectedTemplate && edgeType && edgeType.value) { - // TODO: do we need to clone prompt data? nodeRef[$scope.nodeConfig.nodeId] = { fullUnifiedJobTemplateObject: selectedTemplate, - promptData: _.cloneDeep(promptData), + promptData, isNew: true }; - $scope.treeState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; - $scope.treeState.arrayOfNodesForChart[nodeIndex].isNodeBeingAdded = false; + $scope.graphState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; + $scope.graphState.nodeBeingAdded = null; - $scope.treeState.arrayOfLinksForChart.map( (link) => { + $scope.graphState.arrayOfLinksForChart.map( (link) => { if (link.target.index === nodeIndex) { link.edgeType = edgeType.value; } @@ -412,8 +406,8 @@ export default ['$scope', 'TemplatesService', nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); nodeRef[$scope.nodeConfig.nodeId].isEdited = true; - $scope.treeState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; - $scope.treeState.arrayOfNodesForChart[nodeIndex].isNodeBeingEdited = false; + $scope.graphState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; + $scope.graphState.nodeBeingEdited = null; } } @@ -427,15 +421,15 @@ export default ['$scope', 'TemplatesService', const nodeIndex = chartNodeIdToIndexMapping[$scope.nodeConfig.nodeId]; if ($scope.nodeConfig.mode === "add") { // Remove the placeholder node from the array - $scope.treeState.arrayOfNodesForChart.splice(nodeIndex, 1); + $scope.graphState.arrayOfNodesForChart.splice(nodeIndex, 1); // Update the links let parents = []; let children = []; // Remove any links that reference this node - for( let i = $scope.treeState.arrayOfLinksForChart.length; i--; ){ - const link = $scope.treeState.arrayOfLinksForChart[i]; + for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ + const link = $scope.graphState.arrayOfLinksForChart[i]; if (link.source.index === nodeIndex || link.target.index === nodeIndex) { if (link.source.index === nodeIndex) { @@ -445,7 +439,7 @@ export default ['$scope', 'TemplatesService', const sourceIndex = link.source.index < nodeIndex ? link.source.index : link.source.index - 1; parents.push(sourceIndex); } - $scope.treeState.arrayOfLinksForChart.splice(i, 1); + $scope.graphState.arrayOfLinksForChart.splice(i, 1); } else { if (link.source.index > nodeIndex) { link.source.index--; @@ -462,9 +456,9 @@ export default ['$scope', 'TemplatesService', if (parentIndex === 0) { child.edgeType = "always"; } - $scope.treeState.arrayOfLinksForChart.push({ - source: $scope.treeState.arrayOfNodesForChart[parentIndex], - target: $scope.treeState.arrayOfNodesForChart[child.index], + $scope.graphState.arrayOfLinksForChart.push({ + source: $scope.graphState.arrayOfNodesForChart[parentIndex], + target: $scope.graphState.arrayOfNodesForChart[child.index], edgeType: child.edgeType }); }); @@ -478,13 +472,9 @@ export default ['$scope', 'TemplatesService', } } - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } else if ($scope.nodeConfig.mode === "edit") { - $scope.treeState.arrayOfNodesForChart.map( (node) => { - if (node.index === $scope.nodeConfig.nodeId) { - node.isNodeBeingEdited = false; - } - }); + $scope.graphState.nodeBeingEdited = null; } $scope.formState.showNodeForm = false; $scope.nodeConfig = null; @@ -509,11 +499,7 @@ export default ['$scope', 'TemplatesService', node: nodeRef[nodeToEdit.id] }; - $scope.treeState.arrayOfNodesForChart.map( (node) => { - if (node.index === nodeToEdit.index) { - node.isNodeBeingEdited = true; - } - }); + $scope.graphState.nodeBeingEdited = nodeToEdit.id; $scope.formState.showNodeForm = true; } @@ -526,18 +512,19 @@ export default ['$scope', 'TemplatesService', $scope.startEditLink = (linkToEdit) => { const setupLinkEdit = () => { - linkToEdit.isLinkBeingEdited = true; - // Determine whether or not this link can be removed - // TODO: we already (potentially) loop across this array below - // and we should combine let numberOfParents = 0; - $scope.treeState.arrayOfLinksForChart.forEach((link) => { + $scope.graphState.arrayOfLinksForChart.forEach((link) => { if (link.target.id === linkToEdit.target.id) { numberOfParents++; } }); + $scope.graphState.linkBeingEdited = { + source: linkToEdit.source.id, + target: linkToEdit.target.id + }; + $scope.linkConfig = { mode: "edit", parent: { @@ -564,12 +551,9 @@ export default ['$scope', 'TemplatesService', if ($scope.linkConfig.parent.id !== linkToEdit.source.id || $scope.linkConfig.child.id !== linkToEdit.target.id) { // User is going from editing one link to editing another if ($scope.linkConfig.mode === "add") { - $scope.treeState.arrayOfLinksForChart.splice($scope.treeState.arrayOfLinksForChart.length-1, 1); - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.arrayOfLinksForChart.splice($scope.graphState.arrayOfLinksForChart.length-1, 1); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } - $scope.treeState.arrayOfLinksForChart.forEach((link) => { - link.isLinkBeingEdited = false; - }); setupLinkEdit(); } } else { @@ -587,29 +571,33 @@ export default ['$scope', 'TemplatesService', }; $scope.linkConfig.edgeType = "success"; - $scope.treeState.arrayOfNodesForChart.forEach((node) => { + $scope.graphState.arrayOfNodesForChart.forEach((node) => { node.isInvalidLinkTarget = false; }); - $scope.treeState.arrayOfLinksForChart.push({ - target: $scope.treeState.arrayOfNodesForChart[node.index], - source: $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.parent.id]], - edgeType: "placeholder", - isLinkBeingEdited: true + $scope.graphState.arrayOfLinksForChart.push({ + target: $scope.graphState.arrayOfNodesForChart[node.index], + source: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.parent.id]], + edgeType: "placeholder" }); - $scope.treeState.arrayOfLinksForChart.forEach((link, index) => { + $scope.graphState.linkBeingEdited = { + source: $scope.graphState.arrayOfNodesForChart[node.index].id, + target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.parent.id]].id + }; + + $scope.graphState.arrayOfLinksForChart.forEach((link, index) => { if (link.source.id === 1 && link.target.id === node.id) { - $scope.treeState.arrayOfLinksForChart.splice(index, 1); + $scope.graphState.arrayOfLinksForChart.splice(index, 1); } }); - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.treeState.isLinkMode = false; + $scope.graphState.isLinkMode = false; } else { // This is the first node selected - $scope.treeState.addLinkSource = node.id; + $scope.graphState.addLinkSource = node.id; $scope.linkConfig = { mode: "add", parent: { @@ -622,7 +610,7 @@ export default ['$scope', 'TemplatesService', let invalidLinkTargetIds = []; // Find and mark any ancestors as disabled to prevent cycles - $scope.treeState.arrayOfLinksForChart.forEach((link) => { + $scope.graphState.arrayOfLinksForChart.forEach((link) => { // id=1 is our artificial root node so we don't care about that if (link.source.id !== 1) { if (link.source.id === node.id) { @@ -649,10 +637,10 @@ export default ['$scope', 'TemplatesService', // Filter out the duplicates invalidLinkTargetIds.filter((element, index, array) => index === array.indexOf(element)).forEach((ancestorId) => { - $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[ancestorId]].isInvalidLinkTarget = true; + $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[ancestorId]].isInvalidLinkTarget = true; }); - $scope.treeState.isLinkMode = true; + $scope.graphState.isLinkMode = true; $scope.formState.showLinkForm = true; } @@ -661,22 +649,20 @@ export default ['$scope', 'TemplatesService', }; $scope.confirmLinkForm = (newEdgeType) => { - $scope.treeState.arrayOfLinksForChart.forEach((link) => { + $scope.graphState.arrayOfLinksForChart.forEach((link) => { if (link.source.id === $scope.linkConfig.parent.id && link.target.id === $scope.linkConfig.child.id) { - link.source.isLinkEditParent = false; - link.target.isLinkEditChild = false; link.edgeType = newEdgeType; - link.isLinkBeingEdited = false; } }); if ($scope.linkConfig.mode === "add") { - $scope.treeState.arrayOfNodesForChart.forEach((node) => { + $scope.graphState.arrayOfNodesForChart.forEach((node) => { node.isInvalidLinkTarget = false; }); } - $scope.treeState.addLinkSource = null; + $scope.graphState.linkBeingEdited = null; + $scope.graphState.addLinkSource = null; $scope.formState.showLinkForm = false; $scope.linkConfig = null; $scope.$broadcast("refreshWorkflowChart"); @@ -684,15 +670,15 @@ export default ['$scope', 'TemplatesService', $scope.unlink = () => { // Remove the link - for( let i = $scope.treeState.arrayOfLinksForChart.length; i--; ){ - const link = $scope.treeState.arrayOfLinksForChart[i]; + for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ + const link = $scope.graphState.arrayOfLinksForChart[i]; if (link.source.id === $scope.linkConfig.parent.id && link.target.id === $scope.linkConfig.child.id) { - $scope.treeState.arrayOfLinksForChart.splice(i, 1); + $scope.graphState.arrayOfLinksForChart.splice(i, 1); } } - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); $scope.formState.showLinkForm = false; $scope.linkConfig = null; @@ -701,32 +687,30 @@ export default ['$scope', 'TemplatesService', $scope.cancelLinkForm = () => { if ($scope.linkConfig.mode === "add" && $scope.linkConfig.child) { - $scope.treeState.arrayOfLinksForChart.splice($scope.treeState.arrayOfLinksForChart.length-1, 1); + $scope.graphState.arrayOfLinksForChart.splice($scope.graphState.arrayOfLinksForChart.length-1, 1); let targetIsOrphaned = true; - $scope.treeState.arrayOfLinksForChart.forEach((link) => { + $scope.graphState.arrayOfLinksForChart.forEach((link) => { if (link.target.id === $scope.linkConfig.child.id) { targetIsOrphaned = false; } }); if (targetIsOrphaned) { // Link it to the start node - $scope.treeState.arrayOfLinksForChart.push({ - source: $scope.treeState.arrayOfNodesForChart[0], - target: $scope.treeState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.child.id]], + $scope.graphState.arrayOfLinksForChart.push({ + source: $scope.graphState.arrayOfNodesForChart[0], + target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.child.id]], edgeType: "always" }); } - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } - $scope.treeState.addLinkSource = null; - $scope.treeState.isLinkMode = false; - $scope.formState.showLinkForm = false; - $scope.treeState.arrayOfNodesForChart.forEach((node) => { + $scope.graphState.linkBeingEdited = null; + $scope.graphState.addLinkSource = null; + $scope.graphState.isLinkMode = false; + $scope.graphState.arrayOfNodesForChart.forEach((node) => { node.isInvalidLinkTarget = false; }); - $scope.treeState.arrayOfLinksForChart.forEach((link) => { - link.isLinkBeingEdited = false; - }); + $scope.formState.showLinkForm = false; $scope.linkConfig = null; $scope.$broadcast("refreshWorkflowChart"); }; @@ -752,15 +736,15 @@ export default ['$scope', 'TemplatesService', } // Remove the node from the array - $scope.treeState.arrayOfNodesForChart.splice(nodeIndex, 1); + $scope.graphState.arrayOfNodesForChart.splice(nodeIndex, 1); // Update the links let parents = []; let children = []; // Remove any links that reference this node - for( let i = $scope.treeState.arrayOfLinksForChart.length; i--; ){ - const link = $scope.treeState.arrayOfLinksForChart[i]; + for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ + const link = $scope.graphState.arrayOfLinksForChart[i]; if (link.source.index === nodeIndex || link.target.index === nodeIndex) { if (link.source.index === nodeIndex) { @@ -770,14 +754,14 @@ export default ['$scope', 'TemplatesService', const sourceIndex = link.source.index < nodeIndex ? link.source.index : link.source.index - 1; parents.push(sourceIndex); } - $scope.treeState.arrayOfLinksForChart.splice(i, 1); + $scope.graphState.arrayOfLinksForChart.splice(i, 1); } else { - if (link.source.index > nodeIndex) { - link.source = link.source.index - 1; - } - if (link.target.index > nodeIndex) { - link.target = link.target.index - 1; - } + // if (link.source.index > nodeIndex) { + // link.source.index = link.source.index - 1; + // } + // if (link.target.index > nodeIndex) { + // link.target.index = link.target.index - 1; + // } } } @@ -787,9 +771,9 @@ export default ['$scope', 'TemplatesService', if (parentIndex === 0) { child.edgeType = "always"; } - $scope.treeState.arrayOfLinksForChart.push({ - source: $scope.treeState.arrayOfNodesForChart[parentIndex], - target: $scope.treeState.arrayOfNodesForChart[child.index], + $scope.graphState.arrayOfLinksForChart.push({ + source: $scope.graphState.arrayOfNodesForChart[parentIndex], + target: $scope.graphState.arrayOfNodesForChart[child.index], edgeType: child.edgeType }); }); @@ -807,7 +791,9 @@ export default ['$scope', 'TemplatesService', } } - $scope.treeState.depthMap = WorkflowChartService.generateDepthMap($scope.treeState.arrayOfLinksForChart); + $scope.deleteOverlayVisible = false; + + $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); $scope.nodeToBeDeleted = null; $scope.deleteOverlayVisible = false; @@ -869,7 +855,7 @@ export default ['$scope', 'TemplatesService', let depthMap = WorkflowChartService.generateDepthMap(arrayOfLinksForChart); - $scope.treeState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; } }, function ({ data, status, config }) { ProcessErrors($scope, data, status, null, { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 107b97baab..52c09a3f28 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -74,7 +74,7 @@
{{strings.get('workflow_maker.TOTAL_TEMPLATES')}} - +
@@ -83,7 +83,7 @@
Date: Fri, 9 Nov 2018 10:54:06 -0500 Subject: [PATCH 37/99] bump migration --- ...orkflow_convergence.py => 0053_v340_workflow_convergence.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0052_v340_workflow_convergence.py => 0053_v340_workflow_convergence.py} (85%) diff --git a/awx/main/migrations/0052_v340_workflow_convergence.py b/awx/main/migrations/0053_v340_workflow_convergence.py similarity index 85% rename from awx/main/migrations/0052_v340_workflow_convergence.py rename to awx/main/migrations/0053_v340_workflow_convergence.py index 408c980706..634b7c16ca 100644 --- a/awx/main/migrations/0052_v340_workflow_convergence.py +++ b/awx/main/migrations/0053_v340_workflow_convergence.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0051_v340_job_slicing'), + ('main', '0052_v340_remove_project_scm_delete_on_next_update'), ] operations = [ From b84fc3b1116493983f92195a416b6c7e1ce54d31 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 9 Nov 2018 13:50:39 -0500 Subject: [PATCH 38/99] Fixes for post-rebase bugs --- .../workflow-chart.directive.js | 47 +++++++++- .../forms/workflow-node-form.controller.js | 91 +++++++++++-------- .../forms/workflow-node-form.partial.html | 63 +++++++------ 3 files changed, 130 insertions(+), 71 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index e5bfcc2251..03728c5761 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -507,13 +507,54 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }); baseSvg.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; }); + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); baseSvg.selectAll(".WorkflowChart-nodeTypeLetter") .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; }) - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + .style("display", function (d) { + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); baseSvg.selectAll(".WorkflowChart-nodeStatus") .attr("class", function(d) { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 08b1592f3d..2f876da2e0 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -7,9 +7,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', + 'i18n', function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, TemplatesStrings, CreateSelect2, Empty, generateList, qs, - GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors + GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors, + i18n ) { let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; @@ -23,13 +25,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService delete templateList.fields.smart_status; delete templateList.fields.labels; delete templateList.fieldActions; + templateList.name = 'wf_maker_templates'; + templateList.iterator = 'wf_maker_template'; templateList.fields.name.columnClass = "col-md-8"; + templateList.fields.name.tag = i18n._('WORKFLOW'); + templateList.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}"; templateList.disableRow = "{{ readOnly }}"; templateList.disableRowValue = 'readOnly'; + templateList.basePath = 'unified_job_templates'; templateList.fields.info = { ngInclude: "'/static/partials/job-template-details.html'", type: 'template', columnClass: 'col-md-3', + infoHeaderClass: 'col-md-3', label: '', nosort: true }; @@ -38,6 +46,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.templateList = templateList; let inventorySourceList = _.cloneDeep(InventorySourcesList); + inventorySourceList.name = 'wf_maker_inventory_sources'; + inventorySourceList.iterator = 'wf_maker_inventory_source'; inventorySourceList.maxVisiblePages = 5; inventorySourceList.searchBarFullWidth = true; inventorySourceList.disableRow = "{{ readOnly }}"; @@ -48,6 +58,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService delete projectList.fields.status; delete projectList.fields.scm_type; delete projectList.fields.last_updated; + projectList.name = 'wf_maker_projects'; + projectList.iterator = 'wf_maker_project'; projectList.fields.name.columnClass = "col-md-11"; projectList.maxVisiblePages = 5; projectList.searchBarFullWidth = true; @@ -473,55 +485,56 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService const setupNodeForm = () => { $scope.nodeFormDataLoaded = false; - $scope.template_queryset = { - page_size: '5', + $scope.wf_maker_template_queryset = { + page_size: '10', order_by: 'name', + role_level: 'execute_role', type: 'workflow_job_template,job_template' }; - $scope.templates = []; - $scope.template_dataset = {}; + $scope.wf_maker_templates = []; + $scope.wf_maker_template_dataset = {}; // Go out and GET the list contents for each of the tabs listPromises.push( - qs.search(GetBasePath('unified_job_templates'), $scope.template_queryset) + qs.search(GetBasePath('unified_job_templates'), $scope.wf_maker_template_queryset) .then(function(res) { - $scope.template_dataset = res.data; - $scope.templates = $scope.template_dataset.results; + $scope.wf_maker_template_dataset = res.data; + $scope.wf_maker_templates = $scope.wf_maker_template_dataset.results; }) ); - $scope.project_queryset = { + $scope.wf_maker_project_queryset = { page_size: '5', order_by: 'name' }; - $scope.projects = []; - $scope.project_dataset = {}; + $scope.wf_maker_projects = []; + $scope.wf_maker_project_dataset = {}; listPromises.push( - qs.search(GetBasePath('projects'), $scope.project_queryset) + qs.search(GetBasePath('projects'), $scope.wf_maker_project_queryset) .then(function(res) { - $scope.project_dataset = res.data; - $scope.projects = $scope.project_dataset.results; + $scope.wf_maker_project_dataset = res.data; + $scope.wf_maker_projects = $scope.wf_maker_project_dataset.results; }) ); - $scope.inventory_source_dataset = { + $scope.wf_maker_inventory_source_dataset = { page_size: '5', order_by: 'name', not__source: '' }; - $scope.inventory_sources = []; - $scope.inventory_source_dataset = {}; + $scope.wf_maker_inventory_sources = []; + $scope.wf_maker_inventory_source_dataset = {}; listPromises.push( - qs.search(GetBasePath('inventory_sources'), $scope.inventory_source_dataset) + qs.search(GetBasePath('inventory_sources'), $scope.wf_maker_inventory_source_dataset) .then(function(res) { - $scope.inventory_source_dataset = res.data; - $scope.inventory_sources = $scope.inventory_source_dataset.results; + $scope.wf_maker_inventory_source_dataset = res.data; + $scope.wf_maker_inventory_sources = $scope.wf_maker_inventory_source_dataset.results; }) ); @@ -561,30 +574,30 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService // TODO: make this more concise switch($scope.activeTab) { case 'jobs': - $scope.templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.templates[i].checked = 1; + $scope.wf_maker_templates[i].checked = 1; } else { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates[i].checked = 0; } }); break; case 'project_syncs': - $scope.projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.projects[i].checked = 1; + $scope.wf_maker_projects[i].checked = 1; } else { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects[i].checked = 0; } }); break; case 'inventory_syncs': - $scope.inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.inventory_sources[i].checked = 1; + $scope.wf_maker_inventory_sources[i].checked = 1; } else { - $scope.inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources[i].checked = 0; } }); break; @@ -599,36 +612,36 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } }); - $scope.$watchGroup(['templates', 'projects', 'inventory_sources', 'activeTab'], () => { + $scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab'], () => { // TODO: make this more concise switch($scope.activeTab) { case 'jobs': - $scope.templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach(function(row, i) { if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { - $scope.templates[i].checked = 1; + $scope.wf_maker_templates[i].checked = 1; } else { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates[i].checked = 0; } }); break; case 'project_syncs': - $scope.projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach(function(row, i) { if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { - $scope.projects[i].checked = 1; + $scope.wf_maker_projects[i].checked = 1; } else { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects[i].checked = 0; } }); break; case 'inventory_syncs': - $scope.inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach(function(row, i) { if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { - $scope.inventory_sources[i].checked = 1; + $scope.wf_maker_inventory_sources[i].checked = 1; } else { - $scope.inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources[i].checked = 0; } }); break; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 4677547f47..ee64faadb9 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -16,94 +16,99 @@
No records matched your search.
PLEASE ADD ITEMS TO THIS LIST
-
+
- - + + -
+
- + + + {{wf_maker_template.name}} + + {{:: strings.get('workflow_maker.WORKFLOW') }} + - {{ template.name }}
- +
-
- +
+
-
+
No records matched your search.
-
No Projects Have Been Created
-
+
No Projects Have Been Created
+
- - + - +
+
- + - {{ project.name }} + {{ wf_maker_project.name }}
- +
-
- +
+
-
+
No records matched your search.
-
PLEASE ADD ITEMS TO THIS LIST
-
+
PLEASE ADD ITEMS TO THIS LIST
+
- - + + -
+
- + + + {{ wf_maker_inventory_source.name }} - {{ inventory_source.name }}
- +
From 9f3e272665c3c4f5faf02da56e71e546e5fe2c8d Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 8 Nov 2018 11:22:48 -0500 Subject: [PATCH 39/99] optimize cycle detection --- awx/main/scheduler/dag_simple.py | 63 +++++++++++++++++++++++++----- awx/main/scheduler/dag_workflow.py | 26 +++++++----- docs/workflow.md | 7 +--- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 2e4903b7a0..1e28615cc5 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -6,6 +6,24 @@ class SimpleDAG(object): def __init__(self): self.nodes = [] self.edges = [] + self.root_nodes = set([]) + + ''' + Track node_obj->node index + dict where key is a full workflow node object or whatever we are + storing in ['node_object'] and value is an index to be used into + self.nodes + ''' + self.node_obj_to_node_index = dict() + + ''' + Track per-node from->to edges + + dict where key is the node index in self.nodes and value is a set of + indexes into self.nodes that represent the to edge + [node_from_index] = set([node_to_index,]) + ''' + self.node_from_edges = dict() def __contains__(self, obj): for node in self.nodes: @@ -60,17 +78,36 @@ class SimpleDAG(object): def add_node(self, obj, metadata=None): if self.find_ord(obj) is None: - self.nodes.append(dict(node_object=obj, metadata=metadata)) + ''' + Assume node is a root node until a child is added + ''' + node_index = len(self.nodes) + self.root_nodes.add(node_index) + self.node_obj_to_node_index[obj] = node_index + entry = dict(node_object=obj, metadata=metadata) + self.nodes.append(entry) def add_edge(self, from_obj, to_obj, label=None): from_obj_ord = self.find_ord(from_obj) to_obj_ord = self.find_ord(to_obj) + + ''' + To node is no longer a root node + ''' + self.root_nodes.discard(to_obj_ord) + if from_obj_ord is None and to_obj_ord is None: raise LookupError("From object {} and to object not found".format(from_obj, to_obj)) elif from_obj_ord is None: raise LookupError("From object not found {}".format(from_obj)) elif to_obj_ord is None: raise LookupError("To object not found {}".format(to_obj)) + + if from_obj_ord not in self.node_from_edges: + self.node_from_edges[from_obj_ord] = set([]) + + self.node_from_edges[from_obj_ord].add(to_obj_ord) + self.edges.append((from_obj_ord, to_obj_ord, label)) def add_edges(self, edgelist): @@ -78,10 +115,7 @@ class SimpleDAG(object): self.add_edge(edge_pair[0], edge_pair[1], edge_pair[2]) def find_ord(self, obj): - for idx in range(len(self.nodes)): - if obj == self.nodes[idx]['node_object']: - return idx - return None + return self.node_obj_to_node_index.get(obj, None) def get_dependencies(self, obj, label=None): antecedents = [] @@ -95,6 +129,12 @@ class SimpleDAG(object): antecedents.append(self.nodes[dep]) return antecedents + def get_dependencies_label_oblivious(self, obj): + this_ord = self.find_ord(obj) + + to_node_indexes = self.node_from_edges.get(this_ord, set([])) + return [self.nodes[index] for index in to_node_indexes] + def get_dependents(self, obj, label=None): decendents = [] this_ord = self.find_ord(obj) @@ -116,6 +156,10 @@ class SimpleDAG(object): def get_root_nodes(self): roots = [] + for index in self.root_nodes: + roots.append(self.nodes[index]) + return roots + for n in self.nodes: if len(self.get_dependents(n['node_object'])) < 1: roots.append(n) @@ -126,6 +170,7 @@ class SimpleDAG(object): node_objs_visited = set([]) path = set([]) stack = node_objs + res = False if len(self.nodes) != 0 and len(node_objs) == 0: return True @@ -133,17 +178,17 @@ class SimpleDAG(object): while stack: node_obj = stack.pop() - children = [node['node_object'] for node in self.get_dependencies(node_obj)] + children = [node['node_object'] for node in self.get_dependencies_label_oblivious(node_obj)] children_to_add = filter(lambda node_obj: node_obj not in node_objs_visited, children) if children_to_add: if node_obj in path: - return True + res = True + break path.add(node_obj) stack.append(node_obj) stack.extend(children_to_add) else: node_objs_visited.add(node_obj) path.discard(node_obj) - - return False + return res diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 5e9abe8de9..e47c71965d 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,6 +1,7 @@ # Python import copy +from awx.main.models import WorkflowJobTemplateNode # AWX from awx.main.scheduler.dag_simple import SimpleDAG @@ -14,21 +15,28 @@ class WorkflowDAG(SimpleDAG): def _init_graph(self, workflow_job_or_jt): if hasattr(workflow_job_or_jt, 'workflow_job_template_nodes'): - node_qs = workflow_job_or_jt.workflow_job_template_nodes + workflow_nodes = workflow_job_or_jt.workflow_job_template_nodes elif hasattr(workflow_job_or_jt, 'workflow_job_nodes'): - node_qs = workflow_job_or_jt.workflow_job_nodes + workflow_nodes = workflow_job_or_jt.workflow_job_nodes else: raise RuntimeError("Unexpected object {} {}".format(type(workflow_job_or_jt), workflow_job_or_jt)) - workflow_nodes = node_qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes').all() - for workflow_node in workflow_nodes: + success_nodes = WorkflowJobTemplateNode.success_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + failure_nodes = WorkflowJobTemplateNode.failure_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + always_nodes = WorkflowJobTemplateNode.always_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + + wfn_by_id = dict() + + for workflow_node in workflow_nodes.all(): + wfn_by_id[workflow_node.id] = workflow_node self.add_node(workflow_node) - for node_type in ['success_nodes', 'failure_nodes', 'always_nodes']: - for workflow_node in workflow_nodes: - related_nodes = getattr(workflow_node, node_type).all() - for related_node in related_nodes: - self.add_edge(workflow_node, related_node, node_type) + for edge in success_nodes: + self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'success_nodes') + for edge in failure_nodes: + self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'failure_nodes') + for edge in always_nodes: + self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'always_nodes') ''' Determine if all, relevant, parents node are finished. diff --git a/docs/workflow.md b/docs/workflow.md index 1235353169..26a2beaa42 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -54,11 +54,8 @@ In the event that spawning the workflow would result in recursion, the child wor will be marked as failed with a message explaining that recursion was detected. This is to prevent saturation of the task system with an infinite chain of workflows. -### Tree-Graph Formation and Restrictions -The tree-graph structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. However there are restrictions that must be enforced when setting up new connections. Here are the three restrictions that will raise validation error when break: -* Cycle restriction: According to tree definition, no cycle is allowed. - -> Note: A node can now have all three types of child nodes. +### DAG Formation and Restrictions +The DAG structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. There is one restriction that is enforced when setting up new connections and that is the cycle restriction, since it's a DAG. ### Workflow Run Details A typical workflow run starts by either POSTing to endpoint `/workflow_job_templates/\d+/launch/`, or being triggered automatically by related schedule. At the very first, the workflow job template creates workflow job, and all related workflow job template nodes create workflow job nodes. Right after that, all root nodes are populated with corresponding job resources and start running. If nothing goes wrong, each decision tree will follow its own route to completion. The entire workflow finishes running when all its decision trees complete. From 16a60412cf0c2744216c48a2670f4e42762e887d Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 9 Nov 2018 14:36:22 -0500 Subject: [PATCH 40/99] optimization fix * WorkflowDAG accepts workflow job template and workflow jobs for which to build a graph out of the nodes. The optimized query for each is different. This changeset adds the differing queries for a workflow job. --- awx/main/scheduler/dag_workflow.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index e47c71965d..099655becb 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,7 +1,10 @@ # Python import copy -from awx.main.models import WorkflowJobTemplateNode +from awx.main.models import ( + WorkflowJobTemplateNode, + WorkflowJobNode, +) # AWX from awx.main.scheduler.dag_simple import SimpleDAG @@ -16,14 +19,19 @@ class WorkflowDAG(SimpleDAG): def _init_graph(self, workflow_job_or_jt): if hasattr(workflow_job_or_jt, 'workflow_job_template_nodes'): workflow_nodes = workflow_job_or_jt.workflow_job_template_nodes + success_nodes = WorkflowJobTemplateNode.success_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + failure_nodes = WorkflowJobTemplateNode.failure_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + always_nodes = WorkflowJobTemplateNode.always_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') elif hasattr(workflow_job_or_jt, 'workflow_job_nodes'): workflow_nodes = workflow_job_or_jt.workflow_job_nodes + success_nodes = WorkflowJobNode.success_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') + failure_nodes = WorkflowJobNode.failure_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') + always_nodes = WorkflowJobNode.always_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') else: raise RuntimeError("Unexpected object {} {}".format(type(workflow_job_or_jt), workflow_job_or_jt)) - success_nodes = WorkflowJobTemplateNode.success_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') - failure_nodes = WorkflowJobTemplateNode.failure_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') - always_nodes = WorkflowJobTemplateNode.always_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + print("workflow id {}".format(workflow_job_or_jt.id)) + print("Count of success nodes {}".format(len(success_nodes))) wfn_by_id = dict() From 3dadeb303713e561816724f4d4f66c5d0ba29a54 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 9 Nov 2018 14:56:10 -0500 Subject: [PATCH 41/99] remove print statements --- awx/main/scheduler/dag_workflow.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 099655becb..1eadac8432 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -30,9 +30,6 @@ class WorkflowDAG(SimpleDAG): else: raise RuntimeError("Unexpected object {} {}".format(type(workflow_job_or_jt), workflow_job_or_jt)) - print("workflow id {}".format(workflow_job_or_jt.id)) - print("Count of success nodes {}".format(len(success_nodes))) - wfn_by_id = dict() for workflow_node in workflow_nodes.all(): From 700860e040ab7d2e944cad445f564c0822b9c45e Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 12 Nov 2018 08:48:32 -0500 Subject: [PATCH 42/99] Fix long name tooltip. Fixed bug adding new node before finishing adding new link. Fixed template list column layout. Ensure that we're getting 200 workflow nodes per GET request --- awx/ui/client/src/templates/templates.service.js | 4 ++-- .../workflow-chart/workflow-chart.directive.js | 2 +- .../forms/workflow-node-form.partial.html | 12 ++++++------ .../workflow-maker/workflow-maker.controller.js | 8 ++++++++ .../workflow-results/workflow-results.controller.js | 4 ++-- .../src/workflow-results/workflow-results.route.js | 2 +- 6 files changed, 20 insertions(+), 12 deletions(-) diff --git a/awx/ui/client/src/templates/templates.service.js b/awx/ui/client/src/templates/templates.service.js index 63dfe91590..71ebb17626 100644 --- a/awx/ui/client/src/templates/templates.service.js +++ b/awx/ui/client/src/templates/templates.service.js @@ -133,10 +133,10 @@ export default ['Rest', 'GetBasePath', '$q', 'NextPage', function(Rest, GetBaseP getWorkflowJobTemplateNodes: function(id, page) { var url = GetBasePath('workflow_job_templates'); - url = url + id + '/workflow_nodes'; + url = url + id + '/workflow_nodes?page_size=200'; if(page) { - url += '/?page=' + page; + url += '/&page=' + page; } Rest.setUrl(url); diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 03728c5761..d7d32244f6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -832,7 +832,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("height", tipHeight+20) .attr("class", "WorkflowChart-tooltip") .html(function(){ - return "
" + $filter('sanitize')(resourceName) + "
"; + return "
" + $filter('sanitize')(resourceName) + "
"; }); } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index ee64faadb9..0732e14819 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -7,23 +7,23 @@
-
- +
+
-
+
No records matched your search.
-
PLEASE ADD ITEMS TO THIS LIST
+
PLEASE ADD ITEMS TO THIS LIST
- - diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 824527bcbb..ab2f33e9af 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -307,6 +307,10 @@ export default ['$scope', 'TemplatesService', $scope.cancelNodeForm(); } + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + $scope.graphState.arrayOfNodesForChart.push({ index: $scope.graphState.arrayOfNodesForChart.length, id: workflowMakerNodeIdCounter, @@ -343,6 +347,10 @@ export default ['$scope', 'TemplatesService', $scope.cancelNodeForm(); } + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + $scope.graphState.arrayOfNodesForChart.push({ index: $scope.graphState.arrayOfNodesForChart.length, id: workflowMakerNodeIdCounter, diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index ca11942937..8f7eef7ae8 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -1,9 +1,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', 'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange', - 'ParseVariableString', 'count', '$state', 'i18n', 'WorkflowChartService', + 'ParseVariableString', 'count', '$state', 'i18n', 'WorkflowChartService', '$filter', 'moment', function(workflowData, workflowResultsService, workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, - ParseVariableString, count, $state, i18n, WorkflowChartService, + ParseVariableString, count, $state, i18n, WorkflowChartService, $filter, moment) { var runTimeElapsedTimer = null; let nodeIdToChartNodeIdMapping = {}; diff --git a/awx/ui/client/src/workflow-results/workflow-results.route.js b/awx/ui/client/src/workflow-results/workflow-results.route.js index 84562c4783..06fbb5f814 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.route.js +++ b/awx/ui/client/src/workflow-results/workflow-results.route.js @@ -49,7 +49,7 @@ export default { // flashing as rest data comes in. Provides the list of workflow nodes workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) { var defer = $q.defer(); - Rest.setUrl(workflowData.related.workflow_nodes + '?order_by=id'); + Rest.setUrl(workflowData.related.workflow_nodes + '?order_by=id&page_size=200'); Rest.get() .then(({data}) => { if(data.next) { From 0499d419c374e12db47ae4d13d61618d45afc07d Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 12 Nov 2018 15:06:09 -0500 Subject: [PATCH 43/99] more efficient graph processing * Getting parent nodes from child was inefficient. Optimize it with a hash table like we did for the getting of children. * Getting leaf nodes was inefficient. Optimize it like we did getting root nodes. A node is assumed to be a leaf node until it gets a child. --- awx/main/scheduler/dag_simple.py | 132 ++++++++++++++++------------- awx/main/scheduler/dag_workflow.py | 27 +++--- 2 files changed, 91 insertions(+), 68 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 1e28615cc5..589a18fecd 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,14 +1,13 @@ - class SimpleDAG(object): ''' A simple implementation of a directed acyclic graph ''' def __init__(self): self.nodes = [] - self.edges = [] self.root_nodes = set([]) + self.leaf_nodes = set([]) - ''' + r''' Track node_obj->node index dict where key is a full workflow node object or whatever we are storing in ['node_object'] and value is an index to be used into @@ -16,19 +15,41 @@ class SimpleDAG(object): ''' self.node_obj_to_node_index = dict() - ''' + r''' Track per-node from->to edges - dict where key is the node index in self.nodes and value is a set of - indexes into self.nodes that represent the to edge - [node_from_index] = set([node_to_index,]) + i.e. + { + 'success': { + 1: [2, 3], + 4: [2, 3], + }, + 'failed': { + 1: [5], + } + } ''' - self.node_from_edges = dict() + self.node_from_edges_by_label = dict() + + r''' + Track per-node reverse relationship (child to parent) + + i.e. + { + 'success': { + 2: [1, 4], + 3: [1, 4], + }, + 'failed': { + 5: [1], + } + } + ''' + self.node_to_edges_by_label = dict() def __contains__(self, obj): - for node in self.nodes: - if node['node_object'] == obj: - return True + if self.node['node_object'] in self.node_obj_to_node_index: + return True return False def __len__(self): @@ -83,11 +104,12 @@ class SimpleDAG(object): ''' node_index = len(self.nodes) self.root_nodes.add(node_index) + self.leaf_nodes.add(node_index) self.node_obj_to_node_index[obj] = node_index entry = dict(node_object=obj, metadata=metadata) self.nodes.append(entry) - def add_edge(self, from_obj, to_obj, label=None): + def add_edge(self, from_obj, to_obj, label): from_obj_ord = self.find_ord(from_obj) to_obj_ord = self.find_ord(to_obj) @@ -103,67 +125,61 @@ class SimpleDAG(object): elif to_obj_ord is None: raise LookupError("To object not found {}".format(to_obj)) - if from_obj_ord not in self.node_from_edges: - self.node_from_edges[from_obj_ord] = set([]) + self.node_from_edges_by_label.setdefault(label, dict()) \ + .setdefault(from_obj_ord, []) + self.node_to_edges_by_label.setdefault(label, dict()) \ + .setdefault(to_obj_ord, []) - self.node_from_edges[from_obj_ord].add(to_obj_ord) + self.node_from_edges_by_label[label][from_obj_ord].append(to_obj_ord) + self.node_to_edges_by_label[label][to_obj_ord].append(from_obj_ord) - self.edges.append((from_obj_ord, to_obj_ord, label)) - - def add_edges(self, edgelist): - for edge_pair in edgelist: - self.add_edge(edge_pair[0], edge_pair[1], edge_pair[2]) + ''' + To node is no longer a leaf node + ''' + for l in self.node_to_edges_by_label.keys(): + if len(self.node_to_edges_by_label[l].get(to_obj_ord, [])) != 0: + self.leaf_nodes.discard(to_obj_ord) def find_ord(self, obj): return self.node_obj_to_node_index.get(obj, None) + def _get_dependencies_by_label(self, node_index, label): + return [self.nodes[index] for index in + self.node_from_edges_by_label.get(label, {}) + .get(node_index, [])] + def get_dependencies(self, obj, label=None): - antecedents = [] this_ord = self.find_ord(obj) - for node, dep, lbl in self.edges: - if label: - if node == this_ord and lbl == label: - antecedents.append(self.nodes[dep]) - else: - if node == this_ord: - antecedents.append(self.nodes[dep]) - return antecedents + nodes = [] + if label: + return self._get_dependencies_by_label(this_ord, label) + else: + nodes = [] + map(lambda l: nodes.extend(self._get_dependencies_by_label(this_ord, l)), + self.node_from_edges_by_label.keys()) + return nodes - def get_dependencies_label_oblivious(self, obj): - this_ord = self.find_ord(obj) - - to_node_indexes = self.node_from_edges.get(this_ord, set([])) - return [self.nodes[index] for index in to_node_indexes] + def _get_dependents_by_label(self, node_index, label): + return [self.nodes[index] for index in + self.node_to_edges_by_label.get(label, {}) + .get(node_index, [])] def get_dependents(self, obj, label=None): - decendents = [] this_ord = self.find_ord(obj) - for node, dep, lbl in self.edges: - if label: - if dep == this_ord and lbl == label: - decendents.append(self.nodes[node]) - else: - if dep == this_ord: - decendents.append(self.nodes[node]) - return decendents + nodes = [] + if label: + return self._get_dependents_by_label(this_ord, label) + else: + nodes = [] + map(lambda l: nodes.extend(self._get_dependents_by_label(this_ord, l)), + self.node_to_edges_by_label.keys()) + return nodes def get_leaf_nodes(self): - leafs = [] - for n in self.nodes: - if len(self.get_dependencies(n['node_object'])) < 1: - leafs.append(n) - return leafs + return [self.nodes[index] for index in self.leaf_nodes] def get_root_nodes(self): - roots = [] - for index in self.root_nodes: - roots.append(self.nodes[index]) - return roots - - for n in self.nodes: - if len(self.get_dependents(n['node_object'])) < 1: - roots.append(n) - return roots + return [self.nodes[index] for index in self.root_nodes] def has_cycle(self): node_objs = [node['node_object'] for node in self.get_root_nodes()] @@ -178,7 +194,7 @@ class SimpleDAG(object): while stack: node_obj = stack.pop() - children = [node['node_object'] for node in self.get_dependencies_label_oblivious(node_obj)] + children = [node['node_object'] for node in self.get_dependencies(node_obj)] children_to_add = filter(lambda node_obj: node_obj not in node_objs_visited, children) if children_to_add: diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 1eadac8432..5e8db1a497 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -18,15 +18,23 @@ class WorkflowDAG(SimpleDAG): def _init_graph(self, workflow_job_or_jt): if hasattr(workflow_job_or_jt, 'workflow_job_template_nodes'): + vals = ['from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id'] + filters = { + 'from_workflowjobtemplatenode__workflow_job_template_id': workflow_job_or_jt.id + } workflow_nodes = workflow_job_or_jt.workflow_job_template_nodes - success_nodes = WorkflowJobTemplateNode.success_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') - failure_nodes = WorkflowJobTemplateNode.failure_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') - always_nodes = WorkflowJobTemplateNode.always_nodes.through.objects.filter(from_workflowjobtemplatenode__workflow_job_template_id=workflow_job_or_jt.id).values_list('from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id') + success_nodes = WorkflowJobTemplateNode.success_nodes.through.objects.filter(**filters).values_list(*vals) + failure_nodes = WorkflowJobTemplateNode.failure_nodes.through.objects.filter(**filters).values_list(*vals) + always_nodes = WorkflowJobTemplateNode.always_nodes.through.objects.filter(**filters).values_list(*vals) elif hasattr(workflow_job_or_jt, 'workflow_job_nodes'): + vals = ['from_workflowjobnode_id', 'to_workflowjobnode_id'] + filters = { + 'from_workflowjobnode__workflow_job_id': workflow_job_or_jt.id + } workflow_nodes = workflow_job_or_jt.workflow_job_nodes - success_nodes = WorkflowJobNode.success_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') - failure_nodes = WorkflowJobNode.failure_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') - always_nodes = WorkflowJobNode.always_nodes.through.objects.filter(from_workflowjobnode__workflow_job_id=workflow_job_or_jt.id).values_list('from_workflowjobnode_id', 'to_workflowjobnode_id') + success_nodes = WorkflowJobNode.success_nodes.through.objects.filter(**filters).values_list(*vals) + failure_nodes = WorkflowJobNode.failure_nodes.through.objects.filter(**filters).values_list(*vals) + always_nodes = WorkflowJobNode.always_nodes.through.objects.filter(**filters).values_list(*vals) else: raise RuntimeError("Unexpected object {} {}".format(type(workflow_job_or_jt), workflow_job_or_jt)) @@ -43,7 +51,7 @@ class WorkflowDAG(SimpleDAG): for edge in always_nodes: self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'always_nodes') - ''' + r''' Determine if all, relevant, parents node are finished. Relevant parents are parents that are marked do_not_run False. @@ -147,7 +155,7 @@ class WorkflowDAG(SimpleDAG): return False, False return True, is_failed - ''' + r''' Determine if all nodes have been decided on being marked do_not_run. Nodes that are do_not_run False may become do_not_run True in the future. We know a do_not_run False node will NOT be marked do_not_run True if there @@ -162,10 +170,9 @@ class WorkflowDAG(SimpleDAG): if n.do_not_run is False and not n.job: return False return True - #return not any((n.do_not_run is False and not n.job) for n in workflow_nodes) - ''' + r''' Determine if a node (1) is ready to be marked do_not_run and (2) should be marked do_not_run. From 3f4d14e48d1588fad67b6a33b9ac37ded9389459 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 12 Nov 2018 15:37:26 -0500 Subject: [PATCH 44/99] crawl entire graph when marking DNR * From the root, the code was only going down the did run path to find nodes to mark DNR. This is incorrect, Now, we traverse the entire graph each time to find nodes to mark DNR. --- awx/main/scheduler/dag_workflow.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 5e8db1a497..b1d0b72482 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -218,14 +218,8 @@ class WorkflowDAG(SimpleDAG): obj.do_not_run = True nodes_marked_do_not_run.append(n) - if obj.do_not_run is True: - nodes.extend(self.get_dependencies(obj, 'success_nodes') + - self.get_dependencies(obj, 'failure_nodes') + - self.get_dependencies(obj, 'always_nodes')) - elif obj.job: - if obj.job.status in ['failed', 'error']: - nodes.extend(self.get_dependencies(obj, 'success_nodes')) - elif obj.job.status == 'successful': - nodes.extend(self.get_dependencies(obj, 'failure_nodes')) + nodes.extend(self.get_dependencies(obj, 'success_nodes') + + self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From a176a4b8cfa69a0e5611546440b93a8275bb3ff3 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 13:27:55 -0500 Subject: [PATCH 45/99] remove unused code --- awx/main/scheduler/dag_simple.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 589a18fecd..51acc61f79 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -5,7 +5,6 @@ class SimpleDAG(object): def __init__(self): self.nodes = [] self.root_nodes = set([]) - self.leaf_nodes = set([]) r''' Track node_obj->node index @@ -104,7 +103,6 @@ class SimpleDAG(object): ''' node_index = len(self.nodes) self.root_nodes.add(node_index) - self.leaf_nodes.add(node_index) self.node_obj_to_node_index[obj] = node_index entry = dict(node_object=obj, metadata=metadata) self.nodes.append(entry) @@ -133,13 +131,6 @@ class SimpleDAG(object): self.node_from_edges_by_label[label][from_obj_ord].append(to_obj_ord) self.node_to_edges_by_label[label][to_obj_ord].append(from_obj_ord) - ''' - To node is no longer a leaf node - ''' - for l in self.node_to_edges_by_label.keys(): - if len(self.node_to_edges_by_label[l].get(to_obj_ord, [])) != 0: - self.leaf_nodes.discard(to_obj_ord) - def find_ord(self, obj): return self.node_obj_to_node_index.get(obj, None) @@ -175,9 +166,6 @@ class SimpleDAG(object): self.node_to_edges_by_label.keys()) return nodes - def get_leaf_nodes(self): - return [self.nodes[index] for index in self.leaf_nodes] - def get_root_nodes(self): return [self.nodes[index] for index in self.root_nodes] From 8bb9cfd62aba8e7a9e701bbb2b15224b6b477c38 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 13:28:12 -0500 Subject: [PATCH 46/99] add dag tests --- .../tests/unit/scheduler/test_dag_workflow.py | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 awx/main/tests/unit/scheduler/test_dag_workflow.py diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py new file mode 100644 index 0000000000..30ba6a5200 --- /dev/null +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -0,0 +1,211 @@ +import pytest +import uuid + +from awx.main.scheduler.dag_workflow import WorkflowDAG + + +class Job(): + def __init__(self, status='successful'): + self.status = status + + +class WorkflowNodeBase(object): + def __init__(self, id=None, job=None): + self.id = id if id else uuid.uuid4() + self.job = job + + +class WorkflowNodeDNR(WorkflowNodeBase): + def __init__(self, do_not_run=False, **kwargs): + super(WorkflowNodeDNR, self).__init__(**kwargs) + self.do_not_run = do_not_run + + +class WorkflowNodeUJT(WorkflowNodeDNR): + def __init__(self, unified_job_template=None, **kwargs): + super(WorkflowNodeUJT, self).__init__(**kwargs) + self.unified_job_template = unified_job_template + + +@pytest.fixture +def WorkflowNodeClass(): + return WorkflowNodeBase + +@pytest.fixture +def wf_node_generator(mocker, WorkflowNodeClass): + def fn(**kwargs): + return WorkflowNodeClass(**kwargs) + return fn + +@pytest.fixture +def workflow_dag_1(wf_node_generator): + g = WorkflowDAG() + nodes = [wf_node_generator() for i in range(4)] + map(lambda n: g.add_node(n), nodes) + + r''' + 0 + /\ + S / \ + / \ + 1 | + | | + F | | S + | | + 3 | + \ | + F \ | + \/ + 2 + ''' + g.add_edge(nodes[0], nodes[1], "success_nodes") + g.add_edge(nodes[0], nodes[2], "success_nodes") + g.add_edge(nodes[1], nodes[3], "failure_nodes") + g.add_edge(nodes[3], nodes[2], "failure_nodes") + return (g, nodes) + +class TestWorkflowDAG(): + @pytest.fixture + def workflow_dag_root_children(self, wf_node_generator): + g = WorkflowDAG() + wf_root_nodes = [wf_node_generator() for i in range(0, 10)] + wf_leaf_nodes = [wf_node_generator() for i in range(0, 10)] + + map(lambda n: g.add_node(n), wf_root_nodes + wf_leaf_nodes) + + ''' + Pair up a root node with a single child via an edge + + R1 R2 ... Rx + | | | + | | | + C1 C2 Cx + ''' + map(lambda (i, n): g.add_edge(wf_root_nodes[i], n, 'label'), enumerate(wf_leaf_nodes)) + return (g, wf_root_nodes, wf_leaf_nodes) + + + def test_get_root_nodes(self, workflow_dag_root_children): + (g, wf_root_nodes, ignore) = workflow_dag_root_children + assert set([n.id for n in wf_root_nodes]) == set([n['node_object'].id for n in g.get_root_nodes()]) + + +class TestDNR(): + @pytest.fixture + def WorkflowNodeClass(self): + return WorkflowNodeDNR + + def test_mark_dnr_nodes(self, workflow_dag_1): + (g, nodes) = workflow_dag_1 + + r''' + S0 + /\ + S / \ + / \ + 1 | + | | + F | | S + | | + 3 | + \ | + F \ | + \/ + 2 + ''' + nodes[0].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + assert 0 == len(do_not_run_nodes) + + r''' + S0 + /\ + S / \ + / \ + S1 | + | | + F | | S + | | + DNR 3 | + \ | + F \ | + \/ + 2 + ''' + nodes[1].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + assert 1 == len(do_not_run_nodes) + assert nodes[3] == do_not_run_nodes[0] + +class TestIsWorkflowDone(): + @pytest.fixture + def WorkflowNodeClass(self): + return WorkflowNodeUJT + + @pytest.fixture + def workflow_dag_2(self, workflow_dag_1): + (g, nodes) = workflow_dag_1 + for n in nodes: + n.unified_job_template = uuid.uuid4() + r''' + S0 + /\ + S / \ + / \ + S1 | + | | + F | | S + | | + DNR 3 | + \ | + F \ | + \/ + W2 + ''' + nodes[0].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + nodes[1].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + nodes[2].job = Job(status='waiting') + return (g, nodes) + + @pytest.fixture + def workflow_dag_failed(self, workflow_dag_1): + (g, nodes) = workflow_dag_1 + r''' + S0 + /\ + S / \ + / \ + S1 | + | | + F | | S + | | + DNR 3 | + \ | + F \ | + \/ + F2 + ''' + nodes[0].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + nodes[1].job = Job(status='successful') + do_not_run_nodes = g.mark_dnr_nodes() + nodes[2].job = Job(status='failure') + return (g, nodes) + + def test_is_workflow_done(self, workflow_dag_2): + g = workflow_dag_2[0] + + is_done, is_failed = g.is_workflow_done() + + assert False == is_done + assert False == is_failed + + def test_is_workflow_done_failed(self, workflow_dag_failed): + g = workflow_dag_failed[0] + + is_done, is_failed = g.is_workflow_done() + + assert True == is_done + assert True == is_failed From 1b87e11d8f9a9fd8dbf5fcf4191ef1f5dd481250 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 13:39:41 -0500 Subject: [PATCH 47/99] flake8 --- .../tests/unit/scheduler/test_dag_workflow.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index 30ba6a5200..a7250eb169 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -31,12 +31,14 @@ class WorkflowNodeUJT(WorkflowNodeDNR): def WorkflowNodeClass(): return WorkflowNodeBase + @pytest.fixture def wf_node_generator(mocker, WorkflowNodeClass): def fn(**kwargs): return WorkflowNodeClass(**kwargs) return fn + @pytest.fixture def workflow_dag_1(wf_node_generator): g = WorkflowDAG() @@ -64,6 +66,7 @@ def workflow_dag_1(wf_node_generator): g.add_edge(nodes[3], nodes[2], "failure_nodes") return (g, nodes) + class TestWorkflowDAG(): @pytest.fixture def workflow_dag_root_children(self, wf_node_generator): @@ -137,6 +140,7 @@ class TestDNR(): assert 1 == len(do_not_run_nodes) assert nodes[3] == do_not_run_nodes[0] + class TestIsWorkflowDone(): @pytest.fixture def WorkflowNodeClass(self): @@ -163,9 +167,9 @@ class TestIsWorkflowDone(): W2 ''' nodes[0].job = Job(status='successful') - do_not_run_nodes = g.mark_dnr_nodes() + g.mark_dnr_nodes() nodes[1].job = Job(status='successful') - do_not_run_nodes = g.mark_dnr_nodes() + g.mark_dnr_nodes() nodes[2].job = Job(status='waiting') return (g, nodes) @@ -188,9 +192,9 @@ class TestIsWorkflowDone(): F2 ''' nodes[0].job = Job(status='successful') - do_not_run_nodes = g.mark_dnr_nodes() + g.mark_dnr_nodes() nodes[1].job = Job(status='successful') - do_not_run_nodes = g.mark_dnr_nodes() + g.mark_dnr_nodes() nodes[2].job = Job(status='failure') return (g, nodes) @@ -199,13 +203,13 @@ class TestIsWorkflowDone(): is_done, is_failed = g.is_workflow_done() - assert False == is_done - assert False == is_failed + assert is_done is False + assert is_failed is False def test_is_workflow_done_failed(self, workflow_dag_failed): g = workflow_dag_failed[0] is_done, is_failed = g.is_workflow_done() - assert True == is_done - assert True == is_failed + assert is_done is True + assert is_failed is True From b81d795c000ff11d3be5d4bb0c3bdf01132e2b85 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 13:59:53 -0500 Subject: [PATCH 48/99] fix up dot graph generator * Update graph dot generator to use the new efficient graph --- awx/main/scheduler/dag_simple.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 51acc61f79..fd54b6a34f 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -85,12 +85,14 @@ class SimpleDAG(object): run_status(n['node_object']), color ) - for from_node, to_node, label in self.edges: - doc += "%s -> %s [ label=\"%s\" ];\n" % ( - run_status(self.nodes[from_node]['node_object']), - run_status(self.nodes[to_node]['node_object']), - label, - ) + for label, edges in self.node_from_edges_by_label.iteritems(): + for from_node, to_nodes in edges.iteritems(): + for to_node in to_nodes: + doc += "%s -> %s [ label=\"%s\" ];\n" % ( + run_status(self.nodes[from_node]['node_object']), + run_status(self.nodes[to_node]['node_object']), + label, + ) doc += "}\n" gv_file = open('/awx_devel/graph.gv', 'w') gv_file.write(doc) From ae0d0db62c7552e1505712bd421e6a0dd60aa5d1 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 13 Nov 2018 15:12:13 -0500 Subject: [PATCH 49/99] Added dagre to handle our workflow graph layout. Fixed various workflow related bugs. --- .../workflow-chart.directive.js | 320 ++++++++++++------ .../workflow-maker.controller.js | 3 + awx/ui/client/src/vendor.js | 1 + awx/ui/package-lock.json | 17 + awx/ui/package.json | 1 + 5 files changed, 233 insertions(+), 109 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index d7d32244f6..d4b6ca208e 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -23,8 +23,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge restrict: 'E', link: function(scope, element) { - let marginLeft = 20, - nodeW = 180, + let nodeW = 180, nodeH = 60, rootW = 60, rootH = 40, @@ -32,11 +31,12 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge maxNodeTextLength = 27, windowHeight, windowWidth, + line, zoomObj, baseSvg, svgGroup, graphLoaded, - force; + nodePositionMap = {}; scope.dimensionsSet = false; @@ -54,16 +54,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); function init() { - force = d3.layout.force() - // .gravity(0) - // .linkStrength(2) - // .friction(0.4) - // .charge(-4000) - // .linkDistance(300) - .gravity(0) - .charge(-300) - .linkDistance(300) - .size([windowHeight, windowWidth]); + line = d3.svg.line() + .x(function (d) { + return d.x; + }) + .y(function (d) { + return d.y; + }); zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); @@ -75,7 +72,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge svgGroup = baseSvg.append("g") .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + .attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); } function calcAvailableScreenSpace() { @@ -102,6 +99,37 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return dimensions; } + // Dagre is going to shift the root node around as nodes are added/removed + // This function ensures that the user doesn't experience that + let normalizeY = ((y) => { + return y - nodePositionMap[1].y; + }); + + function lineData(d) { + + let sourceX = nodePositionMap[d.source.id].x + (nodePositionMap[d.source.id].width); + let sourceY = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let targetX = nodePositionMap[d.target.id].x; + let targetY = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + + // There's something off with the math on the root node... + if (d.source.id === 1) { + sourceY = sourceY + 10; + } + + let points = [{ + x: sourceX, + y: sourceY + }, + { + x: targetX, + y: targetY + } + ]; + + return line(points); + } + // TODO: this function is hacky and we need to come up with a better solution // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 function wrap(text) { @@ -137,7 +165,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let scale = d3.event.scale, translation = d3.event.translate; - translation = [translation[0] + (marginLeft*scale), translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; + translation = [translation[0], translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); @@ -156,7 +184,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; - svgGroup.attr("transform", "translate(" + [translateX + (marginLeft*scale), translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); + svgGroup.attr("transform", "translate(" + [translateX, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); zoomObj.scale(scale); zoomObj.translate([translateX, translateY]); } @@ -179,7 +207,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } function resetZoomAndPan() { - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + svgGroup.attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); // Update the zoomObj zoomObj.scale(1); zoomObj.translate([0,0]); @@ -187,16 +215,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function zoomToFitChart() { let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), - startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), availableScreenSpace = calcAvailableScreenSpace(), currentZoomValue = zoomObj.scale(), unscaledH = graphDimensions.height/currentZoomValue, unscaledW = graphDimensions.width/currentZoomValue, scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, - scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft)/unscaledW, + scaleNeededForMaxWidth = (availableScreenSpace.width)/unscaledW, lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), - scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10), - startNodeOffsetFromGraphCenter = Math.round((((rootH/2) + (startNodeDimensions.top/currentZoomValue)) - ((graphDimensions.top/currentZoomValue) + (unscaledH/2)))*scaleToFit); + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10); manualZoom(scaleToFit*100); @@ -204,12 +230,42 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge zoom: scaleToFit }); - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); - zoomObj.translate([marginLeft - scaleToFit*marginLeft, windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); + svgGroup.attr("transform", "translate(0," + (windowHeight/2 - (nodeH*scaleToFit/2)) + ")scale(" + scaleToFit + ")"); + zoomObj.translate([0, windowHeight/2 - (nodeH*scaleToFit/2) - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); } function update() { if(scope.dimensionsSet) { + var g = new dagre.graphlib.Graph(); + + g.setGraph({rankdir: 'LR', nodesep: 30, ranksep: 120}); + + g.setDefaultEdgeLabel(function() { return {}; }); + + scope.graphState.arrayOfNodesForChart.forEach((node) => { + if (node.id === 1) { + if (scope.mode === "details") { + g.setNode(node.id, { label: "", width: 25, height: 25 }); + } else { + g.setNode(node.id, { label: "", width: rootW, height: rootH }); + } + } else { + g.setNode(node.id, { label: "", width: nodeW, height: nodeH }); + } + }); + + scope.graphState.arrayOfLinksForChart.forEach((link) => { + g.setEdge(link.source.id, link.target.id); + }); + + dagre.layout(g); + + nodePositionMap = {}; + + g.nodes().forEach((node) => { + nodePositionMap[node] = g.node(node); + }); + let links = svgGroup.selectAll(".WorkflowChart-link") .data(scope.graphState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); @@ -221,9 +277,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); baseSvg.selectAll(".WorkflowChart-linkPath") + .transition() .attr("class", function(d) { return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) + .attr("d", lineData) .attr('stroke', function(d) { let edgeType = d.edgeType; if(edgeType) { @@ -254,15 +312,64 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge linkClasses.push("WorkflowChart-link--active"); } return linkClasses.join(' '); + }) + .attr("points",function(d) { + let x1 = nodePositionMap[d.target.id].x; + let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; + let y2 = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + + return [pt1, pt2, pt3, pt4].join(" "); }); baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", function(d) { + return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; + }) + .attr("cy", function(d) { + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + return yPos; + }); baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }); + .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; + return translate; + }); // Add any new links let linkEnter = links.enter().append("g") @@ -283,6 +390,22 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }) .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-overlay";}) .call(edit_link) + .attr("points",function(d) { + let x1 = nodePositionMap[d.target.id].x; + let y1 = normalizeY(nodePositionMap[d.target.id].y) + (nodePositionMap[d.target.id].height/2); + let x2 = nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].width; + let y2 = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); + let slope = (y2 - y1)/(x2-x1); + let yIntercept = y1 - slope*x1; + let orthogonalDistance = 8; + + const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); + + return [pt1, pt2, pt3, pt4].join(" "); + }) .on("mouseover", function(d) { if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -290,13 +413,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-linkHovering", true); let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; arrowClass = 'WorkflowChart-tooltipArrow--down'; } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; arrowClass = 'WorkflowChart-tooltipArrow--right'; } @@ -337,10 +460,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); // Add entering links in the parent’s old position. - linkEnter.append("line") + linkEnter.insert("path", "g") .attr("class", function(d) { return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; }) + .attr("d", lineData) .call(edit_link) .on("mouseenter", function(d) { if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { @@ -349,13 +473,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-linkHovering", true); let xPos, yPos, arrowClass; - if (d.source.x === d.target.x) { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - (100/2); - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 100; + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; arrowClass = 'WorkflowChart-tooltipArrow--down'; } else { - xPos = d.source.y + nodeW + ((d.target.y - (d.source.y + nodeW))/2) - 115; - yPos = (d.source.x + nodeH/2 - d.target.x + nodeH/2)/2 + (d.target.x + nodeH/2) - 50; + xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; + yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; arrowClass = 'WorkflowChart-tooltipArrow--right'; } @@ -416,6 +540,23 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("cx", function(d) { + return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; + }) + .attr("cy", function(d) { + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + return yPos; + }) .call(add_node_with_child) .on("mouseover", function(d) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -436,6 +577,23 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .type("cross") ) .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .attr("transform", function(d) { + let translate; + + const normalizedSourceY = normalizeY(nodePositionMap[d.source.id].y); + const halfSourceHeight = nodePositionMap[d.source.id].height/2; + const normalizedTargetY = normalizeY(nodePositionMap[d.target.id].y); + const halfTargetHeight = nodePositionMap[d.target.id].height/2; + + let yPos = (normalizedSourceY + halfSourceHeight + normalizedTargetY + halfTargetHeight)/2; + + if (d.source.id === 1) { + yPos = yPos + 4; + } + + translate = "translate(" + (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2 + "," + yPos + ")"; + return translate; + }) .call(add_node_with_child) .on("mouseover", function(d) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); @@ -448,13 +606,6 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .classed("WorkflowChart-addHovering", false); }); - // Create references to all the link elements so that they can be transitioned - // properly in the tick function - let linkLines = svgGroup.selectAll(".WorkflowChart-link line"); - let linkPolygons = svgGroup.selectAll(".WorkflowChart-link polygon"); - let linkAddBetweenCircle = svgGroup.selectAll(".WorkflowChart-link circle"); - let linkAddBetweenIcon = svgGroup.selectAll(".WorkflowChart-betweenNodesIcon"); - let nodes = svgGroup.selectAll('.WorkflowChart-node') .data(scope.graphState.arrayOfNodesForChart, function(d) { return d.id; }); @@ -462,6 +613,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge nodes.exit().remove(); // Update existing nodes + baseSvg.selectAll(".WorkflowChart-node") + .transition() + .attr("transform", function (d) { + // Update prior x and prior y + d.px = d.x; + d.py = d.y; + return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + }); + baseSvg.selectAll(".WorkflowChart-nodeAddCircle") .style("display", function(d) { return scope.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); @@ -640,7 +800,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .enter() .append('g') .attr("class", "WorkflowChart-node") - .attr("id", function(d){return "node-" + d.id;}); + .attr("id", function(d){return "node-" + d.id;}) + .attr("transform", function (d) { + return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + }); nodeEnter.each(function(d) { let thisNode = d3.select(this); @@ -1064,75 +1227,14 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }); - // TODO: this - // if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart > 1 && !graphLoaded) { - // zoomToFitChart(); - // } + if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart.length > 1 && !graphLoaded) { + zoomToFitChart(); + } graphLoaded = true; // This will make sure that all the link elements appear before the nodes in the dom - // TODO: i don't think this is working... svgGroup.selectAll(".WorkflowChart-node").order(); - - let tick = () => { - linkLines - .each(function(d) { - d.target.y = scope.graphState.depthMap[d.target.id] * 300; - }) - .attr("x1", function(d) { return d.target.y; }) - .attr("y1", function(d) { return d.target.x + (nodeH/2); }) - .attr("x2", function(d) { return d.source.index === 0 ? (scope.mode === 'details' ? d.source.y + 25 : d.source.y + 60) : (d.source.y + nodeW); }) - .attr("y2", function(d) { return d.source.x + (nodeH/2); }); - - linkPolygons - .attr("points",function(d) { - let x1 = d.target.y; - let y1 = d.target.x + (nodeH/2); - let x2 = d.source.index === 0 ? (d.source.y + 60) : (d.source.y + nodeW); - let y2 = d.source.x + (nodeH/2); - let slope = (y2 - y1)/(x2-x1); - let yIntercept = y1 - slope*x1; - let orthogonalDistance = 8; - - const pt1 = [x1, slope*x1 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt2 = [x2, slope*x2 + yIntercept + orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt3 = [x2, slope*x2 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - const pt4 = [x1, slope*x1 + yIntercept - orthogonalDistance*Math.sqrt(1+slope*slope)].join(","); - - return [pt1, pt2, pt3, pt4].join(" "); - }); - - linkAddBetweenCircle - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); - - linkAddBetweenIcon - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); - - nodes - .attr("transform", function(d) { - return "translate(" + d.y + "," + d.x + ")"; }); - }; - - force - .nodes(scope.graphState.arrayOfNodesForChart) - .links(scope.graphState.arrayOfLinksForChart) - .on("tick", tick) - .start(); } else if(!scope.watchDimensionsSet){ scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ @@ -1178,7 +1280,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function node_click() { this.on("click", function(d) { if(d.id !== scope.graphState.nodeBeingAdded && !scope.readOnly){ - if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget) { + if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { $('.WorkflowChart-potentialLink').remove(); scope.selectNodeForLinking({ nodeToStartLink: d diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index ab2f33e9af..b0fcf857a6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -571,6 +571,9 @@ export default ['$scope', 'TemplatesService', }; $scope.selectNodeForLinking = (node) => { + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } if ($scope.linkConfig) { // This is the second node selected $scope.linkConfig.child = { diff --git a/awx/ui/client/src/vendor.js b/awx/ui/client/src/vendor.js index de496ac02d..b1cc68174a 100644 --- a/awx/ui/client/src/vendor.js +++ b/awx/ui/client/src/vendor.js @@ -38,6 +38,7 @@ require('moment'); require('rrule'); require('sprintf-js'); require('reconnectingwebsocket'); +global.dagre = require('dagre'); // D3 + extensions require('d3'); diff --git a/awx/ui/package-lock.json b/awx/ui/package-lock.json index acd5b97f3b..b6bf415a64 100644 --- a/awx/ui/package-lock.json +++ b/awx/ui/package-lock.json @@ -3195,6 +3195,15 @@ "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", "integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g=" }, + "dagre": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.2.tgz", + "integrity": "sha512-TEOOGZOkCOgCG7AoUIq64sJ3d21SMv8tyoqteLpX+UsUsS9Qw8iap4hhogXY4oB3r0bbZuAjO0atAilgCmsE0Q==", + "requires": { + "graphlib": "^2.1.5", + "lodash": "^4.17.4" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -6185,6 +6194,14 @@ "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=", "dev": true }, + "graphlib": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.5.tgz", + "integrity": "sha512-XvtbqCcw+EM5SqQrIetIKKD+uZVNQtDPD1goIg7K73RuRZtVI5rYMdcCVSHm/AS1sCBZ7vt0p5WgXouucHQaOA==", + "requires": { + "lodash": "^4.11.1" + } + }, "growl": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz", diff --git a/awx/ui/package.json b/awx/ui/package.json index facfb83d8d..e5eb180b52 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -117,6 +117,7 @@ "codemirror": "^5.17.0", "components-font-awesome": "^4.6.1", "d3": "^3.5.4", + "dagre": "^0.8.2", "hamsterjs": "^1.1.2", "html-entities": "^1.2.1", "inherits": "^1.0.2", From 6529c1bb46d04f4597309165f79bb9f0e8c6e56a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 16:13:12 -0500 Subject: [PATCH 50/99] update done and fail detection for workflow * Instead of traversing the workflow graph to determine if a workflow is done or has failed; instead, loop through all the nodes in the graph and grab only the relevant nodes. --- awx/main/scheduler/dag_workflow.py | 60 ++++++++++-------------------- awx/main/scheduler/task_manager.py | 4 +- 2 files changed, 23 insertions(+), 41 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index b1d0b72482..bd673f5dc1 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -115,45 +115,26 @@ class WorkflowDAG(SimpleDAG): return cancel_finished def is_workflow_done(self): - root_nodes = self.get_root_nodes() - nodes = root_nodes - is_failed = False + for node in self.nodes: + obj = node['node_object'] + if obj.do_not_run is False and not obj.job: + return False + elif obj.job and obj.job.status not in ['successful', 'failed', 'canceled', 'error']: + return False + return True - for index, n in enumerate(nodes): - obj = n['node_object'] - job = obj.job - - if obj.unified_job_template is None: - is_failed = True - continue - elif obj.do_not_run is False and not job: - return False, False - elif obj.do_not_run is True: - continue - - children_success = self.get_dependencies(obj, 'success_nodes') - children_failed = self.get_dependencies(obj, 'failure_nodes') - children_always = self.get_dependencies(obj, 'always_nodes') - if not is_failed and job.status != 'successful': - children_all = children_success + children_failed + children_always - for child in children_all: - if child['node_object'].job: - break - else: - is_failed = True if children_all else job.status in ['failed', 'canceled', 'error'] - - if job.status == 'canceled': - continue - elif job.status in ['error', 'failed']: - nodes.extend(children_failed + children_always) - elif job.status == 'successful': - nodes.extend(children_success + children_always) - else: - # Job is about to run or is running. Hold our horses and wait for - # the job to finish. We can't proceed down the graph path until we - # have the job result. - return False, False - return True, is_failed + def has_workflow_failed(self): + failed_nodes = [] + for node in self.nodes: + obj = node['node_object'] + if obj.job and obj.job.status in ['failed', 'anceled', 'error']: + failed_nodes.append(node) + for node in failed_nodes: + obj = node['node_object'] + if (len(self.get_dependencies(obj, 'failure_nodes')) + + len(self.get_dependencies(obj, 'always_nodes'))) == 0: + return True + return False r''' Determine if all nodes have been decided on being marked do_not_run. @@ -188,7 +169,7 @@ class WorkflowDAG(SimpleDAG): if node in (self.get_dependencies(p, 'success_nodes') + self.get_dependencies(p, 'always_nodes')): return False - elif p.job.status in ['failed', 'error']: + elif p.job.status in ['failed', 'error', 'canceled']: if node in (self.get_dependencies(p, 'failure_nodes') + self.get_dependencies(p, 'always_nodes')): return False @@ -222,4 +203,3 @@ class WorkflowDAG(SimpleDAG): self.get_dependencies(obj, 'failure_nodes') + self.get_dependencies(obj, 'always_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] - diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index f1d447077e..a60c7f342c 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -163,6 +163,7 @@ class TaskManager(): dag = WorkflowDAG(workflow_job) status_changed = False if workflow_job.cancel_flag: + workflow_job.workflow_nodes.filter(do_not_run=False, job__isnull=True).update(do_not_run=True) logger.debug('Canceling spawned jobs of %s due to cancel flag.', workflow_job.log_format) cancel_finished = dag.cancel_node_jobs() if cancel_finished: @@ -172,11 +173,12 @@ class TaskManager(): workflow_job.save(update_fields=['status', 'start_args']) status_changed = True else: - is_done, has_failed = dag.is_workflow_done() workflow_nodes = dag.mark_dnr_nodes() map(lambda n: n.save(update_fields=['do_not_run']), workflow_nodes) + is_done = dag.is_workflow_done() if not is_done: continue + has_failed = dag.has_workflow_failed() logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful') result.append(workflow_job.id) new_status = 'failed' if has_failed else 'successful' From a6e20eeaaac46ff86fc2d5750e8c5852914d54ae Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 13 Nov 2018 16:18:50 -0500 Subject: [PATCH 51/99] update wf done and failed tests --- awx/main/tests/unit/scheduler/test_dag_workflow.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index a7250eb169..15f4e3a5bc 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -195,21 +195,16 @@ class TestIsWorkflowDone(): g.mark_dnr_nodes() nodes[1].job = Job(status='successful') g.mark_dnr_nodes() - nodes[2].job = Job(status='failure') + nodes[2].job = Job(status='failed') return (g, nodes) def test_is_workflow_done(self, workflow_dag_2): g = workflow_dag_2[0] - is_done, is_failed = g.is_workflow_done() - - assert is_done is False - assert is_failed is False + assert g.is_workflow_done() is False def test_is_workflow_done_failed(self, workflow_dag_failed): g = workflow_dag_failed[0] - is_done, is_failed = g.is_workflow_done() - - assert is_done is True - assert is_failed is True + assert g.is_workflow_done() is True + assert g.has_workflow_failed() is True From 266831e26d42b8dc9e36b0a5634591c1690b3edb Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 14 Nov 2018 16:12:28 -0500 Subject: [PATCH 52/99] add cycle unit test --- .../tests/unit/scheduler/test_dag_simple.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 awx/main/tests/unit/scheduler/test_dag_simple.py diff --git a/awx/main/tests/unit/scheduler/test_dag_simple.py b/awx/main/tests/unit/scheduler/test_dag_simple.py new file mode 100644 index 0000000000..3494f98f27 --- /dev/null +++ b/awx/main/tests/unit/scheduler/test_dag_simple.py @@ -0,0 +1,42 @@ +import pytest + +from awx.main.scheduler.dag_simple import SimpleDAG + + +@pytest.fixture +def node_generator(): + def fn(): + return object() + return fn + + +@pytest.fixture +def simple_cycle_1(node_generator): + g = SimpleDAG() + nodes = [node_generator() for i in range(4)] + map(lambda n: g.add_node(n), nodes) + + r''' + 0 + /\ + / \ + . . + 1---.2 + . | + | | + -----| + . + 3 + ''' + g.add_edge(nodes[0], nodes[1], "success_nodes") + g.add_edge(nodes[0], nodes[2], "success_nodes") + g.add_edge(nodes[2], nodes[3], "success_nodes") + g.add_edge(nodes[2], nodes[1], "success_nodes") + g.add_edge(nodes[1], nodes[2], "success_nodes") + return (g, nodes) + + +def test_has_cycle(simple_cycle_1): + (g, nodes) = simple_cycle_1 + + assert g.has_cycle() is True From 4a6a3b27fa26d7fe63e34b542e98daa712342c27 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 14 Nov 2018 16:48:32 -0500 Subject: [PATCH 53/99] Fixed a number of workflow visualizer bugs. Added loading spinners while data is being loaded/processed. --- .../features/templates/templates.strings.js | 4 +- .../workflow-chart.directive.js | 8 +- .../workflow-chart/workflow-chart.service.js | 39 +- .../forms/workflow-node-form.controller.js | 419 ++++++++++-------- .../forms/workflow-node-form.partial.html | 98 +++- .../workflow-maker/workflow-maker.block.less | 4 + .../workflow-maker.controller.js | 89 +++- .../workflow-results.controller.js | 4 +- 8 files changed, 397 insertions(+), 268 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index baaae1f783..ab77d0d7b1 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -128,7 +128,9 @@ function TemplatesStrings (BaseString) { EDIT_LINK: t.s('EDIT LINK'), VIEW_LINK: t.s('VIEW LINK'), NEW_LINK: t.s('Please click on an available node to form a new link.'), - UNLINK: t.s('UNLINK') + UNLINK: t.s('UNLINK'), + READ_ONLY_PROMPT_VALUES: t.s('The following promptable values were provided when this node was created:'), + READ_ONLY_NO_PROMPT_VALUES: t.s('No promptable values were provided when this node was created.') }; } diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index d4b6ca208e..6d6520a1fa 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -114,7 +114,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // There's something off with the math on the root node... if (d.source.id === 1) { - sourceY = sourceY + 10; + if (scope.mode === "details") { + sourceY = sourceY + 17; + } else { + sourceY = sourceY + 10; + } } let points = [{ @@ -1279,7 +1283,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function node_click() { this.on("click", function(d) { - if(d.id !== scope.graphState.nodeBeingAdded && !scope.readOnly){ + if(d.id !== scope.graphState.nodeBeingAdded){ if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { $('.WorkflowChart-potentialLink').remove(); scope.selectNodeForLinking({ diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js index 4c51ecc1d1..2973dc1e70 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -1,42 +1,5 @@ export default [function(){ return { - generateDepthMap: (arrayOfLinks) => { - let depthMap = {}; - let nodesWithChildren = {}; - - let walkBranch = (nodeId, depth) => { - depthMap[nodeId] = depthMap[nodeId] ? (depth > depthMap[nodeId] ? depth : depthMap[nodeId]) : depth; - if (nodesWithChildren[nodeId]) { - _.forEach(nodesWithChildren[nodeId].children, (childNodeId) => { - walkBranch(childNodeId, depth+1); - }); - } - }; - - let rootNodeIds = []; - arrayOfLinks.forEach(link => { - // link.source.index of 0 is our artificial start node - if (link.source.index !== 0) { - if (!nodesWithChildren[link.source.id]) { - nodesWithChildren[link.source.id] = { - children: [] - }; - } - - nodesWithChildren[link.source.id].children.push(link.target.id); - } else { - // Store the fact that might be a root node - rootNodeIds.push(link.target.id); - } - }); - - _.forEach(rootNodeIds, function(rootNodeId) { - walkBranch(rootNodeId, 1); - depthMap[rootNodeId] = 1; - }); - - return depthMap; - }, generateArraysOfNodesAndLinks: function(allNodes) { let nonRootNodeIds = []; let allNodeIds = []; @@ -77,7 +40,7 @@ export default [function(){ nodeObj.job = node.summary_fields.job; } if(node.summary_fields.unified_job_template) { - nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; + nodeRef[nodeIdCounter].unifiedJobTemplate = nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template; } arrayOfNodesForChart.push(nodeObj); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 2f876da2e0..0faf9af2be 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -7,11 +7,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', - 'i18n', + 'i18n', 'ParseTypeChange', function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, TemplatesStrings, CreateSelect2, Empty, generateList, qs, GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors, - i18n + i18n, ParseTypeChange ) { let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; @@ -139,229 +139,254 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService const finishConfiguringEdit = () => { - let jobTemplate = new JobTemplate(); + if (!$scope.readOnly) { + let jobTemplate = new JobTemplate(); - if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) { - $scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); - const launchConf = $scope.promptData.launchConf; + if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) { + $scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); + const launchConf = $scope.promptData.launchConf; - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.credential_needed_to_start && - !launchConf.ask_variables_on_launch && - launchConf.variables_needed_to_start.length === 0) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - } else { - $scope.showPromptButton = true; - - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; } else { - $scope.promptModalMissingReqFields = false; - } - } - $scope.nodeFormDataLoaded = true; - } else if ( - _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.unified_job_type') === 'job_template' || - _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === 'job_template' - ) { - let promises = [jobTemplate.optionsLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id), jobTemplate.getLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id)]; + $scope.showPromptButton = true; - if (_.has($scope, 'nodeConfig.node.originalNodeObject.related.credentials')) { - Rest.setUrl($scope.nodeConfig.node.originalNodeObject.related.credentials); - promises.push(Rest.get()); - } - - $q.all(promises) - .then((responses) => { - let launchOptions = responses[0].data, - launchConf = responses[1].data, - workflowNodeCredentials = responses[2] ? responses[2].data.results : []; - - let prompts = PromptService.processPromptValues({ - launchConf: responses[1].data, - launchOptions: responses[0].data, - currentValues: $scope.nodeConfig.node.originalNodeObject - }); - - let defaultCredsWithoutOverrides = []; - - prompts.credentials.previousOverrides = _.cloneDeep(workflowNodeCredentials); - - const credentialHasScheduleOverride = (templateDefaultCred) => { - let credentialHasOverride = false; - workflowNodeCredentials.forEach((scheduleCred) => { - if (templateDefaultCred.credential_type === scheduleCred.credential_type) { - if ( - (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || - (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) - ) { - credentialHasOverride = true; - } - } - }); - - return credentialHasOverride; - }; - - if (_.has(launchConf, 'defaults.credentials')) { - launchConf.defaults.credentials.forEach((defaultCred) => { - if (!credentialHasScheduleOverride(defaultCred)) { - defaultCredsWithoutOverrides.push(defaultCred); - } - }); - } - - prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); - - if ((!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeConfig.node.fullUnifiedJobTemplateObject.project) { - $scope.selectedTemplateInvalid = true; + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { + $scope.promptModalMissingReqFields = true; } else { - $scope.selectedTemplateInvalid = false; + $scope.promptModalMissingReqFields = false; } + } + $scope.nodeFormDataLoaded = true; + } else if ( + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.unified_job_type') === 'job_template' || + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === 'job_template' + ) { + let promises = [jobTemplate.optionsLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id), jobTemplate.getLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id)]; - let credentialRequiresPassword = false; + if (_.has($scope, 'nodeConfig.node.originalNodeObject.related.credentials')) { + Rest.setUrl($scope.nodeConfig.node.originalNodeObject.related.credentials); + promises.push(Rest.get()); + } - prompts.credentials.value.forEach((credential) => { - if(credential.inputs) { - if ((credential.inputs.password && credential.inputs.password === "ASK") || - (credential.inputs.become_password && credential.inputs.become_password === "ASK") || - (credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") || - (credential.inputs.vault_password && credential.inputs.vault_password === "ASK") - ) { + $q.all(promises) + .then((responses) => { + let launchOptions = responses[0].data, + launchConf = responses[1].data, + workflowNodeCredentials = responses[2] ? responses[2].data.results : []; + + let prompts = PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data, + currentValues: $scope.nodeConfig.node.originalNodeObject + }); + + let defaultCredsWithoutOverrides = []; + + prompts.credentials.previousOverrides = _.cloneDeep(workflowNodeCredentials); + + const credentialHasScheduleOverride = (templateDefaultCred) => { + let credentialHasOverride = false; + workflowNodeCredentials.forEach((scheduleCred) => { + if (templateDefaultCred.credential_type === scheduleCred.credential_type) { + if ( + (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || + (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) + ) { + credentialHasOverride = true; + } + } + }); + + return credentialHasOverride; + }; + + if (_.has(launchConf, 'defaults.credentials')) { + launchConf.defaults.credentials.forEach((defaultCred) => { + if (!credentialHasScheduleOverride(defaultCred)) { + defaultCredsWithoutOverrides.push(defaultCred); + } + }); + } + + prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); + + if ((!$scope.nodeConfig.node.fullUnifiedJobTemplateObject.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeConfig.node.fullUnifiedJobTemplateObject.project) { + $scope.selectedTemplateInvalid = true; + } else { + $scope.selectedTemplateInvalid = false; + } + + let credentialRequiresPassword = false; + + prompts.credentials.value.forEach((credential) => { + if(credential.inputs) { + if ((credential.inputs.password && credential.inputs.password === "ASK") || + (credential.inputs.become_password && credential.inputs.become_password === "ASK") || + (credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") || + (credential.inputs.vault_password && credential.inputs.vault_password === "ASK") + ) { + credentialRequiresPassword = true; + } + } else if (credential.passwords_needed && credential.passwords_needed.length > 0) { credentialRequiresPassword = true; } - } else if (credential.passwords_needed && credential.passwords_needed.length > 0) { - credentialRequiresPassword = true; - } - }); + }); - $scope.credentialRequiresPassword = credentialRequiresPassword; + $scope.credentialRequiresPassword = credentialRequiresPassword; - if (!launchConf.survey_enabled && - !launchConf.ask_inventory_on_launch && - !launchConf.ask_credential_on_launch && - !launchConf.ask_verbosity_on_launch && - !launchConf.ask_job_type_on_launch && - !launchConf.ask_limit_on_launch && - !launchConf.ask_tags_on_launch && - !launchConf.ask_skip_tags_on_launch && - !launchConf.ask_diff_mode_on_launch && - !launchConf.credential_needed_to_start && - !launchConf.ask_variables_on_launch && - launchConf.variables_needed_to_start.length === 0) { - $scope.showPromptButton = false; - $scope.promptModalMissingReqFields = false; - $scope.nodeFormDataLoaded = true; - } else { - $scope.showPromptButton = true; - - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { - $scope.promptModalMissingReqFields = true; + if (!launchConf.survey_enabled && + !launchConf.ask_inventory_on_launch && + !launchConf.ask_credential_on_launch && + !launchConf.ask_verbosity_on_launch && + !launchConf.ask_job_type_on_launch && + !launchConf.ask_limit_on_launch && + !launchConf.ask_tags_on_launch && + !launchConf.ask_skip_tags_on_launch && + !launchConf.ask_diff_mode_on_launch && + !launchConf.credential_needed_to_start && + !launchConf.ask_variables_on_launch && + launchConf.variables_needed_to_start.length === 0) { + $scope.showPromptButton = false; + $scope.promptModalMissingReqFields = false; + $scope.nodeFormDataLoaded = true; } else { - $scope.promptModalMissingReqFields = false; - } + $scope.showPromptButton = true; - if (responses[1].data.survey_enabled) { - // go out and get the survey questions - jobTemplate.getSurveyQuestions($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) - .then((surveyQuestionRes) => { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { + $scope.promptModalMissingReqFields = true; + } else { + $scope.promptModalMissingReqFields = false; + } - let processed = PromptService.processSurveyQuestions({ - surveyQuestions: surveyQuestionRes.data.spec, - extra_data: _.cloneDeep($scope.nodeConfig.node.originalNodeObject.extra_data) - }); + if (responses[1].data.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) + .then((surveyQuestionRes) => { - $scope.missingSurveyValue = processed.missingSurveyValue; - - $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); - - $scope.nodeConfig.node.promptData = $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - surveyQuestions: surveyQuestionRes.data.spec, - template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id - }; - - surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { - let missingSurveyValue = false; - _.each($scope.promptData.surveyQuestions, (question) => { - if (question.required && (Empty(question.model) || question.model === [])) { - missingSurveyValue = true; - } + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + extra_data: _.cloneDeep($scope.nodeConfig.node.originalNodeObject.extra_data) }); - $scope.missingSurveyValue = missingSurveyValue; - }, true); - checkCredentialsForRequiredPasswords(); + $scope.missingSurveyValue = processed.missingSurveyValue; - watchForPromptChanges(); + $scope.extraVars = (processed.extra_data === '' || _.isEmpty(processed.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(processed.extra_data); - $scope.nodeFormDataLoaded = true; - }); - } else { - $scope.nodeConfig.node.promptData = $scope.promptData = { - launchConf: launchConf, - launchOptions: launchOptions, - prompts: prompts, - template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id - }; + $scope.nodeConfig.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id + }; - checkCredentialsForRequiredPasswords(); + surveyQuestionWatcher = $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); - watchForPromptChanges(); + checkCredentialsForRequiredPasswords(); - $scope.nodeFormDataLoaded = true; + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + }); + } else { + $scope.nodeConfig.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id + }; + + checkCredentialsForRequiredPasswords(); + + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + } } - } - }); - } else { - $scope.nodeFormDataLoaded = true; - } + }); + } else { + $scope.nodeFormDataLoaded = true; + } - if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) { - if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === "job_template") { + if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) { + if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === "job_template") { + $scope.activeTab = "jobs"; + } + + $scope.selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject; + + if ($scope.selectedTemplate.unified_job_type) { + switch ($scope.selectedTemplate.unified_job_type) { + case "job": + $scope.activeTab = "jobs"; + break; + case "project_update": + $scope.activeTab = "project_syncs"; + break; + case "inventory_update": + $scope.activeTab = "inventory_syncs"; + break; + } + } else if ($scope.selectedTemplate.type) { + switch ($scope.selectedTemplate.type) { + case "job_template": + $scope.activeTab = "jobs"; + break; + case "project": + $scope.activeTab = "project_syncs"; + break; + case "inventory_source": + $scope.activeTab = "inventory_syncs"; + break; + } + } + } else { $scope.activeTab = "jobs"; } - - $scope.selectedTemplate = $scope.nodeConfig.node.fullUnifiedJobTemplateObject; - - if ($scope.selectedTemplate.unified_job_type) { - switch ($scope.selectedTemplate.unified_job_type) { - case "job": - $scope.activeTab = "jobs"; - break; - case "project_update": - $scope.activeTab = "project_syncs"; - break; - case "inventory_update": - $scope.activeTab = "inventory_syncs"; - break; - } - } else if ($scope.selectedTemplate.type) { - switch ($scope.selectedTemplate.type) { - case "job_template": - $scope.activeTab = "jobs"; - break; - case "project": - $scope.activeTab = "project_syncs"; - break; - case "inventory_source": - $scope.activeTab = "inventory_syncs"; - break; - } - } } else { - $scope.activeTab = "jobs"; + $scope.jobTags = $scope.nodeConfig.node.originalNodeObject.job_tags ? $scope.nodeConfig.node.originalNodeObject.job_tags.split(',').map((tag) => (tag)) : []; + $scope.skipTags = $scope.nodeConfig.node.originalNodeObject.skip_tags ? $scope.nodeConfig.node.originalNodeObject.skip_tags.split(',').map((tag) => (tag)) : []; + $scope.showJobTags = true; + $scope.showSkipTags = true; + + if (!$.isEmptyObject($scope.nodeConfig.node.originalNodeObject.extra_data)) { + $scope.extraVars = '---\n' + jsyaml.safeDump($scope.nodeConfig.node.originalNodeObject.extra_data); + $scope.showExtraVars = true; + $scope.parseType = 'yaml'; + + ParseTypeChange({ + scope: $scope, + variable: 'extraVars', + field_id: 'workflow_node_form_extra_vars', + readOnly: true + }); + } else { + $scope.extraVars = null; + $scope.showExtraVars = false; + } + + $scope.nodeFormDataLoaded = true; } }; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 0732e14819..b8866b47b1 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -1,11 +1,11 @@
-
{{nodeConfig.mode === 'edit' ? node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
-
+
{{nodeConfig.mode === 'edit' ? nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
+
{{strings.get('workflow_maker.JOBS')}}
{{strings.get('workflow_maker.PROJECT_SYNC')}}
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
-
+
@@ -141,6 +141,98 @@
+
+
+ {{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}} +
+
+ {{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}} +
+
+
{{:: strings.get('prompt.JOB_TYPE') }}
+
+ {{:: strings.get('prompt.PLAYBOOK_RUN') }} + {{:: strings.get('prompt.CHECK') }} +
+
+
+
{{:: strings.get('prompt.INVENTORY') }}
+
+
+
+
{{:: strings.get('prompt.LIMIT') }}
+
+
+
+
{{:: strings.get('prompt.VERBOSITY') }}
+
+
+
+
+ {{:: strings.get('prompt.JOB_TAGS') }}  + + + + +
+
+
+
+ {{tag}} +
+
+
+
+
+
+ {{:: strings.get('prompt.SKIP_TAGS') }}  + + + + +
+
+
+
+ {{tag}} +
+
+
+
+
+
{{:: strings.get('prompt.SHOW_CHANGES') }}
+
+ {{:: strings.get('ON') }} + {{:: strings.get('OFF') }} +
+
+
+
{{:: strings.get('prompt.EXTRA_VARIABLES') }}
+
+ +
+
+
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index 3dd307a426..e2f0e39a2e 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -309,6 +309,10 @@ color: @default-err; } +.WorkflowMaker-readOnlyPromptText { + margin-bottom: 20px; +} + .Key-list { margin: 0; padding: 20px; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index b0fcf857a6..85ebb2aaa7 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -7,9 +7,12 @@ export default ['$scope', 'TemplatesService', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', 'WorkflowChartService', + 'Wait', function ($scope, TemplatesService, ProcessErrors, CreateSelect2, $q, JobTemplate, - Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService) { + Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService, + Wait + ) { $scope.strings = TemplatesStrings; $scope.preventCredsWithPasswords = true; @@ -90,10 +93,12 @@ export default ['$scope', 'TemplatesService', $scope.saveWorkflowMaker = function () { + Wait('start'); + if ($scope.graphState.arrayOfNodesForChart.length > 1) { let addPromises = []; let editPromises = []; - let credentialsToPost = []; + let credentialRequests = []; Object.keys(nodeRef).map((workflowMakerNodeId) => { if (nodeRef[workflowMakerNodeId].isNew) { @@ -114,8 +119,8 @@ export default ['$scope', 'TemplatesService', }); credentialIdsToPost.forEach((credentialToPost) => { - credentialsToPost.push({ - id: data.id, + credentialRequests.push({ + id: data.data.id, data: { id: credentialToPost.id } @@ -128,6 +133,51 @@ export default ['$scope', 'TemplatesService', id: nodeRef[workflowMakerNodeId].originalNodeObject.id, data: buildSendableNodeData(nodeRef[workflowMakerNodeId]) })); + + if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { + let credentialsNotInPriorCredentials = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter(function (credFromPrompt) { + let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); + return !defaultCreds.some(function (defaultCred) { + return credFromPrompt.id === defaultCred.id; + }); + }); + + let credentialsToAdd = credentialsNotInPriorCredentials.filter(function (credNotInPrior) { + let previousOverrides = _.get(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides', []); + return !previousOverrides.some(function (priorCred) { + return credNotInPrior.id === priorCred.id; + }); + }); + + let credentialsToRemove = []; + + if (_.has(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides')) { + credentialsToRemove = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.previousOverrides.filter(function (priorCred) { + return !credentialsNotInPriorCredentials.some(function (credNotInPrior) { + return priorCred.id === credNotInPrior.id; + }); + }); + } + + credentialsToAdd.forEach((credentialToAdd) => { + credentialRequests.push({ + id: nodeRef[workflowMakerNodeId].originalNodeObject.id, + data: { + id: credentialToAdd.id + } + }); + }); + + credentialsToRemove.forEach((credentialToRemove) => { + credentialRequests.push({ + id: nodeRef[workflowMakerNodeId].originalNodeObject.id, + data: { + id: credentialToRemove.id, + disassociate: true + } + }); + }); + } } }); @@ -222,7 +272,7 @@ export default ['$scope', 'TemplatesService', case "success": if ( !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].id) + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -236,7 +286,7 @@ export default ['$scope', 'TemplatesService', case "failure": if ( !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].id) + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -250,7 +300,7 @@ export default ['$scope', 'TemplatesService', case "always": if ( !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || - !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].id) + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) ) { associatePromises.push( TemplatesService.associateWorkflowNode({ @@ -267,7 +317,7 @@ export default ['$scope', 'TemplatesService', $q.all(disassociatePromises) .then(function () { - let credentialPromises = credentialsToPost.map(function (request) { + let credentialPromises = credentialRequests.map(function (request) { return TemplatesService.postWorkflowNodeCredential({ id: request.id, data: request.data @@ -276,12 +326,14 @@ export default ['$scope', 'TemplatesService', return $q.all(associatePromises.concat(credentialPromises)) .then(function () { + Wait('stop'); $scope.closeDialog(); }); }).catch(({ data, status }) => { + Wait('stop'); ProcessErrors($scope, data, status, null, {}); }); }); @@ -294,6 +346,7 @@ export default ['$scope', 'TemplatesService', $q.all(deletePromises) .then(function () { + Wait('stop'); $scope.closeDialog(); $state.transitionTo('templates'); }); @@ -335,8 +388,6 @@ export default ['$scope', 'TemplatesService', workflowMakerNodeIdCounter++; - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.$broadcast("refreshWorkflowChart"); $scope.formState.showNodeForm = true; @@ -383,8 +434,6 @@ export default ['$scope', 'TemplatesService', workflowMakerNodeIdCounter++; - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.$broadcast("refreshWorkflowChart"); $scope.formState.showNodeForm = true; @@ -480,7 +529,6 @@ export default ['$scope', 'TemplatesService', } } - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } else if ($scope.nodeConfig.mode === "edit") { $scope.graphState.nodeBeingEdited = null; } @@ -560,7 +608,6 @@ export default ['$scope', 'TemplatesService', // User is going from editing one link to editing another if ($scope.linkConfig.mode === "add") { $scope.graphState.arrayOfLinksForChart.splice($scope.graphState.arrayOfLinksForChart.length-1, 1); - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } setupLinkEdit(); } @@ -603,8 +650,6 @@ export default ['$scope', 'TemplatesService', } }); - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.graphState.isLinkMode = false; } else { // This is the first node selected @@ -689,8 +734,6 @@ export default ['$scope', 'TemplatesService', } } - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.formState.showLinkForm = false; $scope.linkConfig = null; $scope.$broadcast("refreshWorkflowChart"); @@ -713,7 +756,6 @@ export default ['$scope', 'TemplatesService', edgeType: "always" }); } - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); } $scope.graphState.linkBeingEdited = null; $scope.graphState.addLinkSource = null; @@ -804,8 +846,6 @@ export default ['$scope', 'TemplatesService', $scope.deleteOverlayVisible = false; - $scope.graphState.depthMap = WorkflowChartService.generateDepthMap($scope.graphState.arrayOfLinksForChart); - $scope.nodeToBeDeleted = null; $scope.deleteOverlayVisible = false; @@ -848,7 +888,7 @@ export default ['$scope', 'TemplatesService', let page = 1; let getNodes = function () { - // Get the workflow nodes + Wait('start'); TemplatesService.getWorkflowJobTemplateNodes($scope.workflowJobTemplateObj.id, page) .then(function (data) { for (var i = 0; i < data.data.results.length; i++) { @@ -864,11 +904,12 @@ export default ['$scope', 'TemplatesService', ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); - let depthMap = WorkflowChartService.generateDepthMap(arrayOfLinksForChart); + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; - $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; + Wait('stop'); } }, function ({ data, status, config }) { + Wait('stop'); ProcessErrors($scope, data, status, null, { hdr: $scope.strings.get('error.HEADER'), msg: $scope.strings.get('error.CALL', { diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 8f7eef7ae8..7096bed720 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -175,9 +175,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes)); - let depthMap = WorkflowChartService.generateDepthMap(arrayOfLinksForChart); - - $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart, depthMap }; + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; } $scope.toggleStdoutFullscreen = function() { From d6a8ad0b33873a915a0239d874588779a895d20a Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 15 Nov 2018 10:23:58 -0500 Subject: [PATCH 54/99] treat canceled jobs in wf the same as failed jobs * Also fix spelling mistake that caused workflows to be falsely marked successful in the case of a canceled job. --- awx/main/scheduler/dag_workflow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index bd673f5dc1..05b45d3184 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -90,7 +90,7 @@ class WorkflowDAG(SimpleDAG): continue if obj.job: - if obj.job.status in ['failed', 'error']: + if obj.job.status in ['failed', 'error', 'canceled']: nodes.extend(self.get_dependencies(obj, 'failure_nodes') + self.get_dependencies(obj, 'always_nodes')) elif obj.job.status == 'successful': @@ -127,7 +127,7 @@ class WorkflowDAG(SimpleDAG): failed_nodes = [] for node in self.nodes: obj = node['node_object'] - if obj.job and obj.job.status in ['failed', 'anceled', 'error']: + if obj.job and obj.job.status in ['failed', 'canceled', 'error']: failed_nodes.append(node) for node in failed_nodes: obj = node['node_object'] From c1171fe4ffb08023a873c8bd6fe6b0b854c37f27 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 15 Nov 2018 14:07:11 -0500 Subject: [PATCH 55/99] treat canceled nodes as failed when processing wf * When deciding what jobs to run next, treat canceled as failed. * Also add tests. --- awx/main/scheduler/dag_workflow.py | 2 +- .../tests/unit/scheduler/test_dag_workflow.py | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 05b45d3184..854fb16dc7 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -71,7 +71,7 @@ class WorkflowDAG(SimpleDAG): return False # Node decidedly got a job; check if job is done - if p.job and p.job.status not in ['successful', 'failed', 'error']: + if p.job and p.job.status not in ['successful', 'failed', 'error', 'canceled']: return False return True diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index 15f4e3a5bc..adcae3c748 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -208,3 +208,64 @@ class TestIsWorkflowDone(): assert g.is_workflow_done() is True assert g.has_workflow_failed() is True + +class TestHasWorkflowFailed(): + @pytest.fixture + def WorkflowNodeClass(self): + return WorkflowNodeBase + + @pytest.fixture + def workflow_dag_canceled(self, wf_node_generator): + g = WorkflowDAG() + nodes = [wf_node_generator() for i in range(1)] + map(lambda n: g.add_node(n), nodes) + r''' + F0 + ''' + nodes[0].job = Job(status='canceled') + return (g, nodes) + + @pytest.fixture + def workflow_dag_failure(self, workflow_dag_canceled): + (g, nodes) = workflow_dag_canceled + nodes[0].job.status = 'failed' + return (g, nodes) + + def test_canceled_should_fail(self, workflow_dag_canceled): + (g, nodes) = workflow_dag_canceled + + assert g.has_workflow_failed() is True + + def test_failure_should_fail(self, workflow_dag_failure): + (g, nodes) = workflow_dag_failure + + assert g.has_workflow_failed() is True + + +class TestBFSNodesToRun(): + @pytest.fixture + def WorkflowNodeClass(self): + return WorkflowNodeDNR + + @pytest.fixture + def workflow_dag_canceled(self, wf_node_generator): + g = WorkflowDAG() + nodes = [wf_node_generator() for i in range(4)] + map(lambda n: g.add_node(n), nodes) + r''' + C0 + / | \ + F / A| \ S + / | \ + 1 2 3 + ''' + g.add_edge(nodes[0], nodes[1], "failure_nodes") + g.add_edge(nodes[0], nodes[2], "always_nodes") + g.add_edge(nodes[0], nodes[3], "success_nodes") + nodes[0].job = Job(status='canceled') + return (g, nodes) + + def test_cancel_still_runs_children(self, workflow_dag_canceled): + (g, nodes) = workflow_dag_canceled + + assert set([nodes[1], nodes[2]]) == set(g.bfs_nodes_to_run()) From 1a85fcd2d5b3c260153c797236e66a166e82e0c8 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 15 Nov 2018 15:01:15 -0500 Subject: [PATCH 56/99] update docs to include workflow failure semantic --- docs/workflow.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/workflow.md b/docs/workflow.md index 26a2beaa42..ceb7f8ee65 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -64,7 +64,7 @@ As stated, workflow job templates can be created with populated `extra_vars`. Th Job resources spawned by workflow jobs are needed by workflow to run correctly. Therefore deletion of spawned job resources is blocked while the underlying workflow job is executing. -Other than success and failure, a workflow spawned job resource can also end with status 'error' and 'canceled'. When a workflow spawned job resource errors, all branches starting from that job will stop executing while the rest continue executing. Canceling a workflow spawned job resource follows the same rules. If the unified job template of the node is null (which could be a result of deleting the unified job template or copying a workflow when the user lacks necessary permissions to use the resource), then the branch should stop executing in this case as well. +Other than success and failure, a workflow spawned job resource can also end with status 'error' and 'canceled'. When a workflow spawned job resource errors, it is treated the same as failure. Canceling a workflow spawned job resource is also treated as a failure. If the unified job template of the node is null (which could be a result of deleting the unified job template or copying a workflow when the user lacks necessary permissions to use the resource), then the branch should stop executing in this case as well. A workflow job itself can also be canceled. In this case all its spawned job resources will be canceled if cancelable and following paths stop executing. @@ -80,9 +80,7 @@ Workflow job summary: Starting from Tower 3.2, Workflow jobs support simultaneous job runs just like that of ordinary jobs. It is controlled by `allow_simultaneous` field of underlying workflow job template. By default, simultaneous workflow job runs are disabled and users should be prudent in enabling this functionality. Because the performance boost of simultaneous workflow runs will only manifest when a large portion of jobs contained by a workflow allow simultaneous runs. Otherwise it is expected to have some long-running workflow jobs since its spawned jobs can be in pending state for a long time. -Before Tower 3.3, the 'failed' status of workflow job is not defined. Starting from 3.3 we define a finished workflow job to fail, if at least one of the conditions below satisfies: -* At least one node runs into states `canceled` or `error`. -* At least one leaf node runs into states `failed`, but no child node is spawned to run (no error handler). +A workflow job is marked as failed if a job spawned by a workflow job fails, without a failure handler. A failure handler is a failure or always link in the workflow job template. A job that is canceled is, effectively, considered a failure for purposes of determining if a job nodes is failed. ### Workflow Copy and Relaunch Other than the normal way of creating workflow job templates, it is also possible to copy existing workflow job templates. The resulting new workflow job template will be mostly identical to the original, except for `name` field which will be appended a text to indicate it's a copy. From 281345dd6708c591409a89d7bcd9ae52e49b1615 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 16 Nov 2018 09:56:28 -0500 Subject: [PATCH 57/99] flake8 fix --- awx/main/tests/unit/scheduler/test_dag_workflow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index adcae3c748..4ab8c8e592 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -209,6 +209,7 @@ class TestIsWorkflowDone(): assert g.is_workflow_done() is True assert g.has_workflow_failed() is True + class TestHasWorkflowFailed(): @pytest.fixture def WorkflowNodeClass(self): From 72263c5c7bb04fad4113b64439c91bda822f1d87 Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 16 Nov 2018 12:12:39 -0500 Subject: [PATCH 58/99] Addresses a number of workflow related bugs --- .../workflow-chart/workflow-chart.block.less | 12 +- .../workflow-chart.directive.js | 209 ++++++++++-------- .../workflow-chart/workflow-chart.service.js | 1 - .../forms/workflow-link-form.partial.html | 8 +- .../forms/workflow-node-form.partial.html | 8 +- .../workflow-maker.controller.js | 199 +++++++++-------- 6 files changed, 229 insertions(+), 208 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 0d5130a418..830a02dcd1 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less @@ -186,16 +186,8 @@ margin: auto; } -.WorkflowChart-tooltipArrow--right { - width: 0; - height: 0; - border-top: 10px solid transparent; - border-bottom: 10px solid transparent; - border-left: 10px solid @default-interface-txt; - margin: auto; - position: relative; - right: -55px; - top: -34px; +.WorkflowChart-tooltipArrow { + fill: @default-interface-txt; } .WorkflowChart-dashedNode { diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 6d6520a1fa..4ec4f2495e 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -240,6 +240,81 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function update() { if(scope.dimensionsSet) { + const buildLinkTooltip = (d) => { + let sourceNode = d3.select(`#node-${d.source.id}`); + const sourceNodeX = d3.transform(sourceNode.attr("transform")).translate[0]; + const sourceNodeY = d3.transform(sourceNode.attr("transform")).translate[1]; + let targetNode = d3.select(`#node-${d.target.id}`); + const targetNodeX = d3.transform(targetNode.attr("transform")).translate[0]; + const targetNodeY = d3.transform(targetNode.attr("transform")).translate[1]; + let xPos, yPos, arrowPoints; + if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { + xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 50; + yPos = (sourceNodeY + nodeH + targetNodeY)/2 - 70; + arrowPoints = { + pt1: { + x: xPos + 40, + y: yPos + 47 + }, + pt2: { + x: xPos + 60, + y: yPos + 47 + }, + pt3: { + x: xPos + 50, + y: yPos + 57 + } + }; + } else { + xPos = (sourceNodeX + nodeW + targetNodeX)/2 - 120; + yPos = (sourceNodeY + nodeH + targetNodeY)/2 - 30; + arrowPoints = { + pt1: { + x: xPos + 100, + y: yPos + 17 + }, + pt2: { + x: xPos + 100, + y: yPos + 33 + }, + pt3: { + x: xPos + 110, + y: yPos + 25 + } + }; + } + let edgeTypeLabel; + switch(d.edgeType) { + case "always": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); + break; + case "success": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); + break; + case "failure": + edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); + break; + } + let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); + let linkTooltip = svgGroup.append("g") + .attr("class", "WorkflowChart-tooltip"); + linkTooltip.append("foreignObject") + .attr("transform", `translate(${xPos},${yPos})`) + .attr("width", 100) + .attr("height", 50) + .html(function(){ + return `
+
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
+
${linkInstructionText}
+
`; + }); + linkTooltip.append("polygon") + .attr("class", "WorkflowChart-tooltipArrow") + .attr("points", function() { + return `${arrowPoints.pt1.x},${arrowPoints.pt1.y} ${arrowPoints.pt2.x},${arrowPoints.pt2.y} ${arrowPoints.pt3.x},${arrowPoints.pt3.y}`; + }); + }; + var g = new dagre.graphlib.Graph(); g.setGraph({rankdir: 'LR', nodesep: 30, ranksep: 120}); @@ -336,7 +411,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge baseSvg.selectAll(".WorkflowChart-circleBetweenNodes") .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .attr("cx", function(d) { return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; }) @@ -356,7 +431,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); baseSvg.selectAll(".WorkflowChart-betweenNodesIcon") - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .attr("transform", function(d) { let translate; @@ -411,48 +486,20 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return [pt1, pt2, pt3, pt4].join(" "); }) .on("mouseover", function(d) { - if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + if( + d.edgeType !== 'placeholder' && + !scope.graphState.isLinkMode && + !d.source.isStartNode && + d.source.id !== scope.graphState.nodeBeingAdded && + d.target.id !== scope.graphState.nodeBeingAdded && + scope.mode !== 'details' + ) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-linkHovering", true); - let xPos, yPos, arrowClass; - if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { - xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; - yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; - arrowClass = 'WorkflowChart-tooltipArrow--down'; - } else { - xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; - yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; - arrowClass = 'WorkflowChart-tooltipArrow--right'; - } - - let edgeTypeLabel; - - switch(d.edgeType) { - case "always": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); - break; - case "success": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); - break; - case "failure": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); - break; - } - - let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); - - svgGroup.append("foreignObject") - .attr("transform", `translate(${xPos},${yPos})`) - .attr("width", 100) - .attr("height", 60) - .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; - }); + buildLinkTooltip(d); } - }) .on("mouseout", function(d){ if(!d.source.isStartNode && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { @@ -471,46 +518,19 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("d", lineData) .call(edit_link) .on("mouseenter", function(d) { - if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + if( + d.edgeType !== 'placeholder' && + !scope.graphState.isLinkMode && + !d.source.isStartNode && + d.source.id !== scope.graphState.nodeBeingAdded && + d.target.id !== scope.graphState.nodeBeingAdded && + scope.mode !== 'details' + ) { $(`#link-${d.source.id}-${d.target.id}`).appendTo(`#aw-workflow-chart-g`); - d3.select("#link-" + d.source.id + "-" + d.target.id) + d3.select(`#link-${d.source.id}-${d.target.id}`) .classed("WorkflowChart-linkHovering", true); - let xPos, yPos, arrowClass; - if (nodePositionMap[d.source.id].y === nodePositionMap[d.target.id].y) { - xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 + 45; - yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 107; - arrowClass = 'WorkflowChart-tooltipArrow--down'; - } else { - xPos = (nodePositionMap[d.source.id].x + nodePositionMap[d.target.id].x)/2 - 30; - yPos = (nodePositionMap[d.source.id].y + nodePositionMap[d.target.id].y)/2 - 70; - arrowClass = 'WorkflowChart-tooltipArrow--right'; - } - - let edgeTypeLabel; - - switch(d.edgeType) { - case "always": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ALWAYS'); - break; - case "success": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_SUCCESS'); - break; - case "failure": - edgeTypeLabel = TemplatesStrings.get('workflow_maker.ON_FAILURE'); - break; - } - - let linkInstructionText = !scope.readOnly ? TemplatesStrings.get('workflow_maker.EDIT_LINK_TOOLTIP') : TemplatesStrings.get('workflow_maker.VIEW_LINK_TOOLTIP'); - - svgGroup.append("foreignObject") - .attr("transform", `translate(${xPos},${yPos})`) - .attr("width", 100) - .attr("height", 60) - .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return `
${TemplatesStrings.get('workflow_maker.RUN')}: ${edgeTypeLabel}
${linkInstructionText}
`; - }); + buildLinkTooltip(d); } }) .on("mouseleave", function(d){ @@ -543,7 +563,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) .attr("r", 10) .attr("class", "WorkflowChart-addCircle WorkflowChart-circleBetweenNodes") - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .attr("cx", function(d) { return (nodePositionMap[d.source.id].x + nodePositionMap[d.source.id].width + nodePositionMap[d.target.id].x)/2; }) @@ -580,7 +600,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return (scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.edgeType === 'placeholder' || scope.graphState.isLinkMode || d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .attr("transform", function(d) { let translate; @@ -1263,7 +1283,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function add_node_with_child() { this.on("click", function(d) { - if(!scope.readOnly && !scope.graphState.isLinkMode) { + if(!scope.readOnly && !scope.graphState.isLinkMode && d.edgeType !== 'placeholder') { scope.addNodeWithChild({ link: d }); @@ -1340,25 +1360,32 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }; - if(d.job.id) { + if(d.job.type) { if(d.unifiedJobTemplate) { goToJobResults(d.unifiedJobTemplate.unified_job_type); } else { - // We don't have access to the unified resource and have to make + // We don't have access to the job type and have to make // a GET request in order to find out what type job this was // so that we can route the user to the correct stdout view - - Rest.setUrl(GetBasePath("unified_jobs") + "?id=" + d.job.id); + Rest.setUrl(GetBasePath("workflow_jobs") + `${d.originalNodeObj.workflow_job}/workflow_nodes/?order_by=id`); Rest.get() - .then(function (res) { - if(res.data.results && res.data.results.length > 0) { - goToJobResults(res.data.results[0].type); - } - }) - .catch(({data, status}) => { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Unable to get job: ' + status }); - }); + .then(function (res) { + if (res.data.results && res.data.results.length > 0) { + const { results } = res.data; + const job = results.filter(result => result.summary_fields.job.id === d.job.id); + goToJobResults(job[0].summary_fields.job.type); + } + }) + .catch(({ + data, + status + }) => { + ProcessErrors(scope, data, status, null, { + hdr: 'Error!', + msg: 'Unable to get job: ' + status + }); + }); } } }); diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js index 2973dc1e70..d807f7d24c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -97,7 +97,6 @@ export default [function(){ return { arrayOfNodesForChart, arrayOfLinksForChart, - chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter: nodeIdCounter diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html index 2bf0315447..cf384b6411 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -1,8 +1,8 @@ -
{{readOnly ? strings.get('workflow_maker.VIEW_LINK') : (linkConfig.mode === 'add' ? strings.get('workflow_maker.ADD_LINK') : strings.get('workflow_maker.EDIT_LINK')) }} | {{linkConfig.parent.name}} {{linkConfig.child ? 'to ' + linkConfig.child.name : ''}}
+
{{readOnly ? strings.get('workflow_maker.VIEW_LINK') : (linkConfig.mode === 'add' ? strings.get('workflow_maker.ADD_LINK') : strings.get('workflow_maker.EDIT_LINK')) }} | {{linkConfig.source.name}} {{linkConfig.target ? 'to ' + linkConfig.target.name : ''}}
-
{{:: strings.get('workflow_maker.NEW_LINK')}}
- +
{{:: strings.get('workflow_maker.NEW_LINK')}}
+
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index b8866b47b1..e48a4e4f08 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -1,11 +1,11 @@
-
{{nodeConfig.mode === 'edit' ? nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
-
+
{{nodeConfig.mode === 'edit' ? nodeConfig.node.fullUnifiedJobTemplateObject.name || nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}
+
{{strings.get('workflow_maker.JOBS')}}
{{strings.get('workflow_maker.PROJECT_SYNC')}}
{{strings.get('workflow_maker.INVENTORY_SYNC')}}
-
+
@@ -141,7 +141,7 @@
-
+
{ - if (foo.source.id === link.source.id && foo.target.id === link.target.id) { - foo.source = $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[workflowMakerNodeIdCounter]]; + $scope.graphState.arrayOfLinksForChart.forEach((linkToCompare) => { + if (linkToCompare.source.id === link.source.id && linkToCompare.target.id === link.target.id) { + linkToCompare.source = {id: workflowMakerNodeIdCounter}; } }); @@ -440,7 +433,7 @@ export default ['$scope', 'TemplatesService', }; $scope.confirmNodeForm = function(selectedTemplate, promptData, edgeType) { - const nodeIndex = chartNodeIdToIndexMapping[$scope.nodeConfig.nodeId]; + const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { if (selectedTemplate && edgeType && edgeType.value) { nodeRef[$scope.nodeConfig.nodeId] = { @@ -449,11 +442,10 @@ export default ['$scope', 'TemplatesService', isNew: true }; - $scope.graphState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; $scope.graphState.nodeBeingAdded = null; $scope.graphState.arrayOfLinksForChart.map( (link) => { - if (link.target.index === nodeIndex) { + if (link.target.id === nodeId) { link.edgeType = edgeType.value; } }); @@ -463,11 +455,16 @@ export default ['$scope', 'TemplatesService', nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); nodeRef[$scope.nodeConfig.nodeId].isEdited = true; - $scope.graphState.arrayOfNodesForChart[nodeIndex].unifiedJobTemplate = selectedTemplate; $scope.graphState.nodeBeingEdited = null; } } + $scope.graphState.arrayOfNodesForChart.map( (node) => { + if (node.id === nodeId) { + node.unifiedJobTemplate = selectedTemplate; + } + }); + $scope.formState.showNodeForm = false; $scope.nodeConfig = null; @@ -475,10 +472,15 @@ export default ['$scope', 'TemplatesService', }; $scope.cancelNodeForm = function() { - const nodeIndex = chartNodeIdToIndexMapping[$scope.nodeConfig.nodeId]; + const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { // Remove the placeholder node from the array - $scope.graphState.arrayOfNodesForChart.splice(nodeIndex, 1); + for( let i = $scope.graphState.arrayOfNodesForChart.length; i--; ){ + if ($scope.graphState.arrayOfNodesForChart[i].id === nodeId) { + $scope.graphState.arrayOfNodesForChart.splice(i, 1); + i = 0; + } + } // Update the links let parents = []; @@ -488,47 +490,30 @@ export default ['$scope', 'TemplatesService', for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ const link = $scope.graphState.arrayOfLinksForChart[i]; - if (link.source.index === nodeIndex || link.target.index === nodeIndex) { - if (link.source.index === nodeIndex) { - const targetIndex = link.target.index < nodeIndex ? link.target.index : link.target.index - 1; - children.push({index: targetIndex, edgeType: link.edgeType}); - } else if (link.target.index === nodeIndex) { - const sourceIndex = link.source.index < nodeIndex ? link.source.index : link.source.index - 1; - parents.push(sourceIndex); + if (link.source.id === nodeId || link.target.id === nodeId) { + if (link.source.id === nodeId) { + children.push({id: link.target.id, edgeType: link.edgeType}); + } else if (link.target.id === nodeId) { + parents.push(link.source.id); } $scope.graphState.arrayOfLinksForChart.splice(i, 1); - } else { - if (link.source.index > nodeIndex) { - link.source.index--; - } - if (link.target.index > nodeIndex) { - link.target.index--; - } } } // Add the new links - parents.forEach((parentIndex) => { + parents.forEach((parentId) => { children.forEach((child) => { - if (parentIndex === 0) { + if (parentId === 1) { child.edgeType = "always"; } $scope.graphState.arrayOfLinksForChart.push({ - source: $scope.graphState.arrayOfNodesForChart[parentIndex], - target: $scope.graphState.arrayOfNodesForChart[child.index], + source: {id: parentId}, + target: {id: child.id}, edgeType: child.edgeType }); }); }); - delete chartNodeIdToIndexMapping[$scope.nodeConfig.nodeId]; - - for (const key in chartNodeIdToIndexMapping) { - if (chartNodeIdToIndexMapping[key] > nodeIndex) { - chartNodeIdToIndexMapping[key]--; - } - } - } else if ($scope.nodeConfig.mode === "edit") { $scope.graphState.nodeBeingEdited = null; } @@ -544,7 +529,7 @@ export default ['$scope', 'TemplatesService', $scope.cancelLinkForm(); } - if (!$scope.nodeConfig || ($scope.nodeConfig && $scope.nodeConfig.nodeId !== nodeToEdit.index)) { + if (!$scope.nodeConfig || ($scope.nodeConfig && $scope.nodeConfig.nodeId !== nodeToEdit.id)) { if ($scope.nodeConfig) { $scope.cancelNodeForm(); } @@ -583,11 +568,11 @@ export default ['$scope', 'TemplatesService', $scope.linkConfig = { mode: "edit", - parent: { + source: { id: linkToEdit.source.id, name: _.get(linkToEdit, 'source.unifiedJobTemplate.name') || "" }, - child: { + target: { id: linkToEdit.target.id, name: _.get(linkToEdit, 'target.unifiedJobTemplate.name') || "" }, @@ -604,7 +589,7 @@ export default ['$scope', 'TemplatesService', } if ($scope.linkConfig) { - if ($scope.linkConfig.parent.id !== linkToEdit.source.id || $scope.linkConfig.child.id !== linkToEdit.target.id) { + if ($scope.linkConfig.source.id !== linkToEdit.source.id || $scope.linkConfig.target.id !== linkToEdit.target.id) { // User is going from editing one link to editing another if ($scope.linkConfig.mode === "add") { $scope.graphState.arrayOfLinksForChart.splice($scope.graphState.arrayOfLinksForChart.length-1, 1); @@ -621,27 +606,31 @@ export default ['$scope', 'TemplatesService', if ($scope.nodeConfig) { $scope.cancelNodeForm(); } + // User was add/editing a link and then hit the link icon + if ($scope.linkConfig && $scope.linkConfig.target) { + $scope.cancelLinkForm(); + } if ($scope.linkConfig) { // This is the second node selected - $scope.linkConfig.child = { + $scope.linkConfig.target = { id: node.id, name: node.unifiedJobTemplate.name }; $scope.linkConfig.edgeType = "success"; - $scope.graphState.arrayOfNodesForChart.forEach((node) => { - node.isInvalidLinkTarget = false; + $scope.graphState.arrayOfNodesForChart.forEach((nodeToUpdate) => { + nodeToUpdate.isInvalidLinkTarget = false; }); $scope.graphState.arrayOfLinksForChart.push({ - target: $scope.graphState.arrayOfNodesForChart[node.index], - source: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.parent.id]], + source: {id: $scope.linkConfig.source.id}, + target: {id: node.id}, edgeType: "placeholder" }); $scope.graphState.linkBeingEdited = { - source: $scope.graphState.arrayOfNodesForChart[node.index].id, - target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.parent.id]].id + source: {id: $scope.linkConfig.source.id}, + target: {id: node.id} }; $scope.graphState.arrayOfLinksForChart.forEach((link, index) => { @@ -656,7 +645,7 @@ export default ['$scope', 'TemplatesService', $scope.graphState.addLinkSource = node.id; $scope.linkConfig = { mode: "add", - parent: { + source: { id: node.id, name: node.unifiedJobTemplate.name } @@ -693,7 +682,11 @@ export default ['$scope', 'TemplatesService', // Filter out the duplicates invalidLinkTargetIds.filter((element, index, array) => index === array.indexOf(element)).forEach((ancestorId) => { - $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[ancestorId]].isInvalidLinkTarget = true; + $scope.graphState.arrayOfNodesForChart.forEach((node) => { + if (node.id === ancestorId) { + node.isInvalidLinkTarget = true; + } + }); }); $scope.graphState.isLinkMode = true; @@ -706,7 +699,7 @@ export default ['$scope', 'TemplatesService', $scope.confirmLinkForm = (newEdgeType) => { $scope.graphState.arrayOfLinksForChart.forEach((link) => { - if (link.source.id === $scope.linkConfig.parent.id && link.target.id === $scope.linkConfig.child.id) { + if (link.source.id === $scope.linkConfig.source.id && link.target.id === $scope.linkConfig.target.id) { link.edgeType = newEdgeType; } }); @@ -729,7 +722,7 @@ export default ['$scope', 'TemplatesService', for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ const link = $scope.graphState.arrayOfLinksForChart[i]; - if (link.source.id === $scope.linkConfig.parent.id && link.target.id === $scope.linkConfig.child.id) { + if (link.source.id === $scope.linkConfig.source.id && link.target.id === $scope.linkConfig.target.id) { $scope.graphState.arrayOfLinksForChart.splice(i, 1); } } @@ -740,19 +733,19 @@ export default ['$scope', 'TemplatesService', }; $scope.cancelLinkForm = () => { - if ($scope.linkConfig.mode === "add" && $scope.linkConfig.child) { + if ($scope.linkConfig.mode === "add" && $scope.linkConfig.target) { $scope.graphState.arrayOfLinksForChart.splice($scope.graphState.arrayOfLinksForChart.length-1, 1); let targetIsOrphaned = true; $scope.graphState.arrayOfLinksForChart.forEach((link) => { - if (link.target.id === $scope.linkConfig.child.id) { + if (link.target.id === $scope.linkConfig.target.id) { targetIsOrphaned = false; } }); if (targetIsOrphaned) { // Link it to the start node $scope.graphState.arrayOfLinksForChart.push({ - source: $scope.graphState.arrayOfNodesForChart[0], - target: $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[$scope.linkConfig.child.id]], + source: {id: 1}, + target: {id: $scope.linkConfig.target.id}, edgeType: "always" }); } @@ -782,53 +775,69 @@ export default ['$scope', 'TemplatesService', $scope.confirmDeleteNode = function () { if ($scope.nodeToBeDeleted) { - const nodeIndex = $scope.nodeToBeDeleted.index; + const nodeId = $scope.nodeToBeDeleted.id; - if ($scope.linkBeingWorkedOn) { + if ($scope.linkConfig) { $scope.cancelLinkForm(); } // Remove the node from the array - $scope.graphState.arrayOfNodesForChart.splice(nodeIndex, 1); + for( let i = $scope.graphState.arrayOfNodesForChart.length; i--; ){ + if ($scope.graphState.arrayOfNodesForChart[i].id === nodeId) { + $scope.graphState.arrayOfNodesForChart.splice(i, 1); + i = 0; + } + } // Update the links let parents = []; let children = []; + let linkParentMapping = {}; // Remove any links that reference this node for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ const link = $scope.graphState.arrayOfLinksForChart[i]; - if (link.source.index === nodeIndex || link.target.index === nodeIndex) { - if (link.source.index === nodeIndex) { - const targetIndex = link.target.index < nodeIndex ? link.target.index : link.target.index - 1; - children.push({index: targetIndex, edgeType: link.edgeType}); - } else if (link.target.index === nodeIndex) { - const sourceIndex = link.source.index < nodeIndex ? link.source.index : link.source.index - 1; - parents.push(sourceIndex); + if (!linkParentMapping[link.target.id]) { + linkParentMapping[link.target.id] = []; + } + + linkParentMapping[link.target.id].push(link.source.id); + + if (link.source.id === nodeId || link.target.id === nodeId) { + if (link.source.id === nodeId) { + children.push({id: link.target.id, edgeType: link.edgeType}); + } else if (link.target.id === nodeId) { + parents.push(link.source.id); } $scope.graphState.arrayOfLinksForChart.splice(i, 1); - } else { - // if (link.source.index > nodeIndex) { - // link.source.index = link.source.index - 1; - // } - // if (link.target.index > nodeIndex) { - // link.target.index = link.target.index - 1; - // } } } // Add the new links - parents.forEach((parentIndex) => { + parents.forEach((parentId) => { children.forEach((child) => { - if (parentIndex === 0) { - child.edgeType = "always"; + if (parentId === 1) { + // We only want to create a link from the start node to this node if it + // doesn't have any other parents + if(linkParentMapping[child.id].length === 1) { + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: parentId}, + target: {id: child.id}, + edgeType: "always" + }); + } + } else { + // We don't want to add a link that already exists + if (!linkParentMapping[child.id].includes(parentId)) { + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: parentId}, + target: {id: child.id}, + edgeType: child.edgeType + }); + } } - $scope.graphState.arrayOfLinksForChart.push({ - source: $scope.graphState.arrayOfNodesForChart[parentIndex], - target: $scope.graphState.arrayOfNodesForChart[child.index], - edgeType: child.edgeType - }); + }); }); @@ -838,12 +847,6 @@ export default ['$scope', 'TemplatesService', delete nodeRef[$scope.nodeToBeDeleted.id]; - for (const key in chartNodeIdToIndexMapping) { - if (chartNodeIdToIndexMapping[key] > $scope.nodeToBeDeleted.index) { - chartNodeIdToIndexMapping[key]--; - } - } - $scope.deleteOverlayVisible = false; $scope.nodeToBeDeleted = null; @@ -902,7 +905,7 @@ export default ['$scope', 'TemplatesService', let arrayOfLinksForChart = []; let arrayOfNodesForChart = []; - ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); + ({arrayOfNodesForChart, arrayOfLinksForChart, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; From 00d71cea50a8e3c98515c92d285389a01e255e49 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 16 Nov 2018 13:37:11 -0500 Subject: [PATCH 59/99] detect workflow nodes without job templates * Fail workflow job run when encountering a Workflow Job Nodes with no related job templates. --- awx/main/scheduler/dag_workflow.py | 26 +++++++++++ .../tests/functional/models/test_workflow.py | 20 ++++++--- .../tests/unit/scheduler/test_dag_workflow.py | 43 +++---------------- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 854fb16dc7..67834d6149 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -127,6 +127,9 @@ class WorkflowDAG(SimpleDAG): failed_nodes = [] for node in self.nodes: obj = node['node_object'] + + if obj.unified_job_template is None: + return True if obj.job and obj.job.status in ['failed', 'canceled', 'error']: failed_nodes.append(node) for node in failed_nodes: @@ -179,6 +182,21 @@ class WorkflowDAG(SimpleDAG): return False return True + r''' + Useful in a failure scenario. Will mark all nodes that might have run a job + and haven't already run a job as do_not_run=True + + Return an array of workflow nodes that were marked do_not_run = True + ''' + def _mark_all_remaining_nodes_dnr(self): + objs = [] + for node in self.nodes: + obj = node['node_object'] + if obj.do_not_run is False and not obj.job: + obj.do_not_run = True + objs.append(obj) + return objs + def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() nodes = copy.copy(root_nodes) @@ -191,6 +209,14 @@ class WorkflowDAG(SimpleDAG): continue node_ids_visited.add(obj.id) + ''' + Special case. On a workflow job template relaunch it's possible for + the job template associated with the job to have been deleted. If + this is the case, fail the workflow job and mark it done. + ''' + if obj.unified_job_template is None: + return self._mark_all_remaining_nodes_dnr() + if obj.do_not_run is False and not obj.job and n not in root_nodes: parent_nodes = filter(lambda n: not n.do_not_run, [p['node_object'] for p in self.get_dependents(obj)]) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index 87ddc8747c..17fa705c47 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -64,7 +64,9 @@ class TestWorkflowDAGFunctional(TransactionTestCase): def test_workflow_done(self): wfj = self.workflow_job(states=['failed', None, None, 'successful', None]) dag = WorkflowDAG(workflow_job=wfj) - is_done, has_failed = dag.is_workflow_done() + assert 3 == len(dag.mark_dnr_nodes()) + is_done = dag.is_workflow_done() + has_failed = dag.has_workflow_failed() self.assertTrue(is_done) self.assertFalse(has_failed) @@ -73,28 +75,36 @@ class TestWorkflowDAGFunctional(TransactionTestCase): jt.delete() relaunched = wfj.create_relaunch_workflow_job() dag = WorkflowDAG(workflow_job=relaunched) - is_done, has_failed = dag.is_workflow_done() + dag.mark_dnr_nodes() + is_done = dag.is_workflow_done() + has_failed = dag.has_workflow_failed() self.assertTrue(is_done) self.assertTrue(has_failed) def test_workflow_fails_for_no_error_handler(self): wfj = self.workflow_job(states=['successful', 'failed', None, None, None]) dag = WorkflowDAG(workflow_job=wfj) - is_done, has_failed = dag.is_workflow_done() + dag.mark_dnr_nodes() + is_done = dag.is_workflow_done() + has_failed = dag.has_workflow_failed() self.assertTrue(is_done) self.assertTrue(has_failed) def test_workflow_fails_leaf(self): wfj = self.workflow_job(states=['successful', 'successful', 'failed', None, None]) dag = WorkflowDAG(workflow_job=wfj) - is_done, has_failed = dag.is_workflow_done() + dag.mark_dnr_nodes() + is_done = dag.is_workflow_done() + has_failed = dag.has_workflow_failed() self.assertTrue(is_done) self.assertTrue(has_failed) def test_workflow_not_finished(self): wfj = self.workflow_job(states=['new', None, None, None, None]) dag = WorkflowDAG(workflow_job=wfj) - is_done, has_failed = dag.is_workflow_done() + dag.mark_dnr_nodes() + is_done = dag.is_workflow_done() + has_failed = dag.has_workflow_failed() self.assertFalse(is_done) self.assertFalse(has_failed) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index 4ab8c8e592..e126456d4e 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -9,33 +9,18 @@ class Job(): self.status = status -class WorkflowNodeBase(object): - def __init__(self, id=None, job=None): +class WorkflowNode(object): + def __init__(self, id=None, job=None, do_not_run=False, unified_job_template=None): self.id = id if id else uuid.uuid4() self.job = job - - -class WorkflowNodeDNR(WorkflowNodeBase): - def __init__(self, do_not_run=False, **kwargs): - super(WorkflowNodeDNR, self).__init__(**kwargs) self.do_not_run = do_not_run - - -class WorkflowNodeUJT(WorkflowNodeDNR): - def __init__(self, unified_job_template=None, **kwargs): - super(WorkflowNodeUJT, self).__init__(**kwargs) self.unified_job_template = unified_job_template @pytest.fixture -def WorkflowNodeClass(): - return WorkflowNodeBase - - -@pytest.fixture -def wf_node_generator(mocker, WorkflowNodeClass): +def wf_node_generator(mocker): def fn(**kwargs): - return WorkflowNodeClass(**kwargs) + return WorkflowNode(**kwargs) return fn @@ -94,12 +79,10 @@ class TestWorkflowDAG(): class TestDNR(): - @pytest.fixture - def WorkflowNodeClass(self): - return WorkflowNodeDNR - def test_mark_dnr_nodes(self, workflow_dag_1): (g, nodes) = workflow_dag_1 + for n in nodes: + n.unified_job_template = object() r''' S0 @@ -142,10 +125,6 @@ class TestDNR(): class TestIsWorkflowDone(): - @pytest.fixture - def WorkflowNodeClass(self): - return WorkflowNodeUJT - @pytest.fixture def workflow_dag_2(self, workflow_dag_1): (g, nodes) = workflow_dag_1 @@ -211,10 +190,6 @@ class TestIsWorkflowDone(): class TestHasWorkflowFailed(): - @pytest.fixture - def WorkflowNodeClass(self): - return WorkflowNodeBase - @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() @@ -244,14 +219,10 @@ class TestHasWorkflowFailed(): class TestBFSNodesToRun(): - @pytest.fixture - def WorkflowNodeClass(self): - return WorkflowNodeDNR - @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator() for i in range(4)] + nodes = [wf_node_generator(unified_job_template=object()) for i in range(4)] map(lambda n: g.add_node(n), nodes) r''' C0 From 676c068b71d9fa43fb9b22e703b4c4537b1637c5 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 16 Nov 2018 14:45:12 -0500 Subject: [PATCH 60/99] add job_description to failed workflow node * When workflow job fails because a workflow job node doesn't have a related unified_job_template note that with an error on the workflow job's job_description * When a workflow job fails because a failure path isn't defined, note that on the workflow job job_description --- awx/main/scheduler/dag_workflow.py | 6 +++--- awx/main/scheduler/task_manager.py | 8 ++++++-- awx/main/tests/functional/models/test_workflow.py | 9 ++++++--- awx/main/tests/unit/scheduler/test_dag_workflow.py | 12 ++++++------ 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 67834d6149..0b7aeb7140 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -129,15 +129,15 @@ class WorkflowDAG(SimpleDAG): obj = node['node_object'] if obj.unified_job_template is None: - return True + return True, "Workflow job node {} related unified job template missing".format(obj.id) if obj.job and obj.job.status in ['failed', 'canceled', 'error']: failed_nodes.append(node) for node in failed_nodes: obj = node['node_object'] if (len(self.get_dependencies(obj, 'failure_nodes')) + len(self.get_dependencies(obj, 'always_nodes'))) == 0: - return True - return False + return True, "Workflow job node {} has a status of '{}' without an error handler path".format(obj.id, obj.job.status) + return False, None r''' Determine if all nodes have been decided on being marked do_not_run. diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index a60c7f342c..ccc953cada 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -178,14 +178,18 @@ class TaskManager(): is_done = dag.is_workflow_done() if not is_done: continue - has_failed = dag.has_workflow_failed() + has_failed, reason = dag.has_workflow_failed() logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful') result.append(workflow_job.id) new_status = 'failed' if has_failed else 'successful' logger.debug(six.text_type("Transitioning {} to {} status.").format(workflow_job.log_format, new_status)) + update_fields = ['status', 'start_args'] workflow_job.status = new_status + if reason: + workflow_job.job_explanation = reason + update_fields.append('job_explanation') workflow_job.start_args = '' # blank field to remove encrypted passwords - workflow_job.save(update_fields=['status', 'start_args']) + workflow_job.save(update_fields=update_fields) status_changed = True if status_changed: workflow_job.websocket_emit_status(workflow_job.status) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index 17fa705c47..41c8be70e3 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -66,9 +66,10 @@ class TestWorkflowDAGFunctional(TransactionTestCase): dag = WorkflowDAG(workflow_job=wfj) assert 3 == len(dag.mark_dnr_nodes()) is_done = dag.is_workflow_done() - has_failed = dag.has_workflow_failed() + has_failed, reason = dag.has_workflow_failed() self.assertTrue(is_done) self.assertFalse(has_failed) + assert reason is None # verify that relaunched WFJ fails if a JT leaf is deleted for jt in JobTemplate.objects.all(): @@ -77,9 +78,10 @@ class TestWorkflowDAGFunctional(TransactionTestCase): dag = WorkflowDAG(workflow_job=relaunched) dag.mark_dnr_nodes() is_done = dag.is_workflow_done() - has_failed = dag.has_workflow_failed() + has_failed, reason = dag.has_workflow_failed() self.assertTrue(is_done) self.assertTrue(has_failed) + assert "Workflow job node {} related unified job template missing".format(wfj.workflow_nodes.all()[0].id) def test_workflow_fails_for_no_error_handler(self): wfj = self.workflow_job(states=['successful', 'failed', None, None, None]) @@ -104,9 +106,10 @@ class TestWorkflowDAGFunctional(TransactionTestCase): dag = WorkflowDAG(workflow_job=wfj) dag.mark_dnr_nodes() is_done = dag.is_workflow_done() - has_failed = dag.has_workflow_failed() + has_failed, reason = dag.has_workflow_failed() self.assertFalse(is_done) self.assertFalse(has_failed) + assert reason is None @pytest.mark.django_db diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index e126456d4e..03a02d748c 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -27,7 +27,7 @@ def wf_node_generator(mocker): @pytest.fixture def workflow_dag_1(wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator() for i in range(4)] + nodes = [wf_node_generator(unified_job_template=object()) for i in range(4)] map(lambda n: g.add_node(n), nodes) r''' @@ -183,17 +183,17 @@ class TestIsWorkflowDone(): assert g.is_workflow_done() is False def test_is_workflow_done_failed(self, workflow_dag_failed): - g = workflow_dag_failed[0] + (g, nodes) = workflow_dag_failed assert g.is_workflow_done() is True - assert g.has_workflow_failed() is True + assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[2].id)) class TestHasWorkflowFailed(): @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator() for i in range(1)] + nodes = [wf_node_generator(unified_job_template=object()) for i in range(1)] map(lambda n: g.add_node(n), nodes) r''' F0 @@ -210,12 +210,12 @@ class TestHasWorkflowFailed(): def test_canceled_should_fail(self, workflow_dag_canceled): (g, nodes) = workflow_dag_canceled - assert g.has_workflow_failed() is True + assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'canceled' without an error handler path".format(nodes[0].id)) def test_failure_should_fail(self, workflow_dag_failure): (g, nodes) = workflow_dag_failure - assert g.has_workflow_failed() is True + assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[0].id)) class TestBFSNodesToRun(): From 5b459e3c5d16ce4a352e3297f978c668dda4305c Mon Sep 17 00:00:00 2001 From: mabashian Date: Fri, 16 Nov 2018 17:18:10 -0500 Subject: [PATCH 61/99] Code cleanup. Fixed bugs with workflow results page including details links --- .../workflow-chart.directive.js | 58 ++--- .../workflow-chart/workflow-chart.service.js | 7 +- .../workflow-controls.directive.js | 4 +- .../forms/workflow-node-form.controller.js | 39 ++- .../workflow-maker.controller.js | 234 +++++++++--------- .../workflow-maker.partial.html | 2 +- .../workflow-results.controller.js | 21 +- 7 files changed, 163 insertions(+), 202 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 4ec4f2495e..2d3e21cf0b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -62,7 +62,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge return d.y; }); - zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); + zoomObj = d3.behavior.zoom().scaleExtent([0.1, 2]); baseSvg = d3.select(element[0]).append("svg") .attr("class", "WorkflowChart-svg") @@ -1130,9 +1130,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge thisNode.append("foreignObject") .attr("x", nodeW - 6) .attr("y", nodeH/2 - 9) + .attr("height", "17px") + .attr("width", "13px") .style("font-size","14px") .html(function () { - return ``; + return ``; }) .attr("class", "WorkflowChart-nodeLinkIcon") .style("display", function(d) { return d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) @@ -1347,46 +1349,18 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge d3.select(this).style("text-decoration", null); }); this.on("click", function(d) { - - let goToJobResults = function(job_type) { - if(job_type === 'job') { - $state.go('output', {id: d.job.id, type: 'playbook'}); - } - else if(job_type === 'inventory_update') { - $state.go('output', {id: d.job.id, type: 'inventory'}); - } - else if(job_type === 'project_update') { - $state.go('output', {id: d.job.id, type: 'project'}); - } - }; - - if(d.job.type) { - if(d.unifiedJobTemplate) { - goToJobResults(d.unifiedJobTemplate.unified_job_type); - } - else { - // We don't have access to the job type and have to make - // a GET request in order to find out what type job this was - // so that we can route the user to the correct stdout view - Rest.setUrl(GetBasePath("workflow_jobs") + `${d.originalNodeObj.workflow_job}/workflow_nodes/?order_by=id`); - Rest.get() - .then(function (res) { - if (res.data.results && res.data.results.length > 0) { - const { results } = res.data; - const job = results.filter(result => result.summary_fields.job.id === d.job.id); - goToJobResults(job[0].summary_fields.job.type); - } - }) - .catch(({ - data, - status - }) => { - ProcessErrors(scope, data, status, null, { - hdr: 'Error!', - msg: 'Unable to get job: ' + status - }); - }); - } + if(d.job.type === 'job') { + $state.go('output', {id: d.job.id, type: 'playbook'}); + } + else if(d.job.type === 'inventory_update') { + $state.go('output', {id: d.job.id, type: 'inventory'}); + } + else if(d.job.type === 'project_update') { + $state.go('output', {id: d.job.id, type: 'project'}); + } else if (d.job.type === 'workflow_job') { + $state.go('workflowResults', { + id: d.job.id, + }); } }); } diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js index d807f7d24c..76f9e6e2d6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -1,6 +1,6 @@ export default [function(){ return { - generateArraysOfNodesAndLinks: function(allNodes) { + generateArraysOfNodesAndLinks: (allNodes) => { let nonRootNodeIds = []; let allNodeIds = []; let arrayOfLinksForChart = []; @@ -15,10 +15,7 @@ export default [function(){ isStartNode: true, unifiedJobTemplate: { name: "START" - }, - fixed: true, - x: 0, - y: 0 + } } ]; nodeIdCounter++; diff --git a/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js b/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js index 8b0e3bfe47..5e2833c7f0 100644 --- a/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js @@ -21,7 +21,7 @@ export default ['templateUrl', scope.zoom = 100; $( "#slider" ).slider({ value:100, - min: 50, + min: 10, max: 200, step: 10, slide: function( event, ui ) { @@ -54,7 +54,7 @@ export default ['templateUrl', }; scope.zoomOut = function() { - scope.zoom = Math.floor((scope.zoom - 10) / 10) * 10 > 50 ? Math.floor((scope.zoom - 10) / 10) * 10 : 50; + scope.zoom = Math.floor((scope.zoom - 10) / 10) * 10 > 10 ? Math.floor((scope.zoom - 10) / 10) * 10 : 10; $("#slider").slider('value',scope.zoom); scope.zoomChart({ zoom: scope.zoom diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 0faf9af2be..7a5011efb2 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -88,7 +88,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService 'missingSurveyValue' ]; - promptWatcher = $scope.$watchGroup(promptDataToWatch, function() { + promptWatcher = $scope.$watchGroup(promptDataToWatch, () => { let missingPromptValue = false; if ($scope.missingSurveyValue) { missingPromptValue = true; @@ -524,7 +524,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService listPromises.push( qs.search(GetBasePath('unified_job_templates'), $scope.wf_maker_template_queryset) - .then(function(res) { + .then((res) => { $scope.wf_maker_template_dataset = res.data; $scope.wf_maker_templates = $scope.wf_maker_template_dataset.results; }) @@ -540,7 +540,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService listPromises.push( qs.search(GetBasePath('projects'), $scope.wf_maker_project_queryset) - .then(function(res) { + .then((res) => { $scope.wf_maker_project_dataset = res.data; $scope.wf_maker_projects = $scope.wf_maker_project_dataset.results; }) @@ -557,7 +557,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService listPromises.push( qs.search(GetBasePath('inventory_sources'), $scope.wf_maker_inventory_source_dataset) - .then(function(res) { + .then((res) => { $scope.wf_maker_inventory_source_dataset = res.data; $scope.wf_maker_inventory_sources = $scope.wf_maker_inventory_source_dataset.results; }) @@ -567,14 +567,14 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService .then(() => { if ($scope.nodeConfig.mode === "edit") { // Make sure that we have the full unified job template object - if (!$scope.nodeConfig.node.fullUnifiedJobTemplate && _.get($scope, 'nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.unified_job_type') === 'job') { + if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject && _.get($scope, 'nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.unified_job_type') === 'job') { // This is a node that we got back from the api with an incomplete // unified job template so we're going to pull down the whole object TemplatesService.getUnifiedJobTemplate($scope.nodeConfig.node.originalNodeObject.summary_fields.unified_job_template.id) - .then(function({data}) { + .then(({data}) => { $scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0]; finishConfiguringEdit(); - }, function(error) { + }, (error) => { ProcessErrors($scope, error.data, error.status, null, { hdr: 'Error!', msg: 'Failed to get unified job template. GET returned ' + @@ -590,16 +590,15 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); }; - $scope.openPromptModal = function() { + $scope.openPromptModal = () => { $scope.promptData.triggerModalOpen = true; }; - $scope.toggle_row = function(selectedRow) { + $scope.toggle_row = (selectedRow) => { if (!$scope.readOnly) { - // TODO: make this more concise switch($scope.activeTab) { case 'jobs': - $scope.wf_maker_templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach((row, i) => { if (row.id === selectedRow.id) { $scope.wf_maker_templates[i].checked = 1; @@ -609,7 +608,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); break; case 'project_syncs': - $scope.wf_maker_projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach((row, i) => { if (row.id === selectedRow.id) { $scope.wf_maker_projects[i].checked = 1; } else { @@ -618,7 +617,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); break; case 'inventory_syncs': - $scope.wf_maker_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach((row, i) => { if (row.id === selectedRow.id) { $scope.wf_maker_inventory_sources[i].checked = 1; } else { @@ -638,11 +637,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); $scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab'], () => { - // TODO: make this more concise + const unifiedJobTemplateId = _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') || _.get($scope, 'nodeConfig.node.unifiedJobTemplate.id') || null; switch($scope.activeTab) { case 'jobs': - $scope.wf_maker_templates.forEach(function(row, i) { - if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.wf_maker_templates.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { $scope.wf_maker_templates[i].checked = 1; } else { @@ -651,8 +650,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); break; case 'project_syncs': - $scope.wf_maker_projects.forEach(function(row, i) { - if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.wf_maker_projects.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { $scope.wf_maker_projects[i].checked = 1; } else { @@ -661,8 +660,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }); break; case 'inventory_syncs': - $scope.wf_maker_inventory_sources.forEach(function(row, i) { - if(_.hasIn($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.id') && row.id === $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) { + $scope.wf_maker_inventory_sources.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { $scope.wf_maker_inventory_sources[i].checked = 1; } else { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 4bff1cecac..7ff6d67536 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -14,86 +14,121 @@ export default ['$scope', 'TemplatesService', Wait ) { - $scope.strings = TemplatesStrings; - $scope.preventCredsWithPasswords = true; - let deletedNodeIds = []; - let workflowMakerNodeIdCounter = 1; + let workflowMakerNodeIdCounter; let nodeIdToChartNodeIdMapping = {}; let nodeRef = {}; + let allNodes = []; + let page = 1; + $scope.strings = TemplatesStrings; + $scope.preventCredsWithPasswords = true; $scope.showKey = false; $scope.toggleKey = () => $scope.showKey = !$scope.showKey; $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; - $scope.readOnly = !_.get($scope, 'workflowJobTemplateObj.summary_fields.user_capabilities.edit'); - $scope.formState = { 'showNodeForm': false, 'showLinkForm': false }; - let buildSendableNodeData = function (node) { - // Create the node - let sendableNodeData = { - extra_data: {}, - inventory: null, - job_type: null, - job_tags: null, - skip_tags: null, - limit: null, - diff_mode: null, - verbosity: null, - credential: null - }; + let getNodes = () => { + Wait('start'); + TemplatesService.getWorkflowJobTemplateNodes($scope.workflowJobTemplateObj.id, page) + .then(({data}) => { + for (var i = 0; i < data.results.length; i++) { + allNodes.push(data.results[i]); + } + if (data.next) { + // Get the next page + page++; + getNodes(); + } else { + let arrayOfLinksForChart = []; + let arrayOfNodesForChart = []; - if (_.has(node, 'fullUnifiedJobTemplateObject')) { - sendableNodeData.unified_job_template = node.fullUnifiedJobTemplateObject.id; - } + ({arrayOfNodesForChart, arrayOfLinksForChart, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); - if (_.has(node, 'promptData.extraVars')) { - if (_.get(node, 'promptData.launchConf.defaults.extra_vars')) { - const defaultVars = jsyaml.safeLoad(node.promptData.launchConf.defaults.extra_vars); + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; - // Only include extra vars that differ from the template default vars - _.forOwn(node.promptData.extraVars, (value, key) => { - if (!defaultVars[key] || defaultVars[key] !== value) { - sendableNodeData.extra_data[key] = value; - } + Wait('stop'); + } + }, ({ data, status, config }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER'), + msg: $scope.strings.get('error.CALL', { + path: `${config.url}`, + action: `${config.method}`, + status + }) }); - if (_.isEmpty(sendableNodeData.extra_data)) { - delete sendableNodeData.extra_data; - } - } else { - if (_.has(node, 'promptData.extraVars') && !_.isEmpty(node.promptData.extraVars)) { - sendableNodeData.extra_data = node.promptData.extraVars; - } - } - } - - // Check to see if the user has provided any prompt values that are different - // from the defaults in the job template - - if (_.has(node, 'fullUnifiedJobTemplateObject') && node.fullUnifiedJobTemplateObject.type === "job_template" && node.promptData) { - sendableNodeData = PromptService.bundlePromptDataForSaving({ - promptData: node.promptData, - dataToSave: sendableNodeData }); - } - - return sendableNodeData; }; - $scope.closeWorkflowMaker = function() { + getNodes(); + + $scope.closeWorkflowMaker = () => { // Revert the data to the master which was created when the dialog was opened $scope.graphState.nodeTree = angular.copy($scope.graphStateMaster); $scope.closeDialog(); }; - $scope.saveWorkflowMaker = function () { + $scope.saveWorkflowMaker = () => { Wait('start'); + let buildSendableNodeData = (node) => { + // Create the node + let sendableNodeData = { + extra_data: {}, + inventory: null, + job_type: null, + job_tags: null, + skip_tags: null, + limit: null, + diff_mode: null, + verbosity: null, + credential: null + }; + + if (_.has(node, 'fullUnifiedJobTemplateObject')) { + sendableNodeData.unified_job_template = node.fullUnifiedJobTemplateObject.id; + } + + if (_.has(node, 'promptData.extraVars')) { + if (_.get(node, 'promptData.launchConf.defaults.extra_vars')) { + const defaultVars = jsyaml.safeLoad(node.promptData.launchConf.defaults.extra_vars); + + // Only include extra vars that differ from the template default vars + _.forOwn(node.promptData.extraVars, (value, key) => { + if (!defaultVars[key] || defaultVars[key] !== value) { + sendableNodeData.extra_data[key] = value; + } + }); + if (_.isEmpty(sendableNodeData.extra_data)) { + delete sendableNodeData.extra_data; + } + } else { + if (_.has(node, 'promptData.extraVars') && !_.isEmpty(node.promptData.extraVars)) { + sendableNodeData.extra_data = node.promptData.extraVars; + } + } + } + + // Check to see if the user has provided any prompt values that are different + // from the defaults in the job template + + if (_.has(node, 'fullUnifiedJobTemplateObject') && node.fullUnifiedJobTemplateObject.type === "job_template" && node.promptData) { + sendableNodeData = PromptService.bundlePromptDataForSaving({ + promptData: node.promptData, + dataToSave: sendableNodeData + }); + } + + return sendableNodeData; + }; + if ($scope.graphState.arrayOfNodesForChart.length > 1) { let addPromises = []; let editPromises = []; @@ -110,9 +145,9 @@ export default ['$scope', 'TemplatesService', if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { // This finds the credentials that were selected in the prompt but don't occur // in the template defaults - let credentialIdsToPost = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter(function (credFromPrompt) { + let credentialIdsToPost = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter((credFromPrompt) => { let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); - return !defaultCreds.some(function (defaultCred) { + return !defaultCreds.some((defaultCred) => { return credFromPrompt.id === defaultCred.id; }); }); @@ -134,16 +169,16 @@ export default ['$scope', 'TemplatesService', })); if (_.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.ask_credential_on_launch')) { - let credentialsNotInPriorCredentials = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter(function (credFromPrompt) { + let credentialsNotInPriorCredentials = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.value.filter((credFromPrompt) => { let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); - return !defaultCreds.some(function (defaultCred) { + return !defaultCreds.some((defaultCred) => { return credFromPrompt.id === defaultCred.id; }); }); - let credentialsToAdd = credentialsNotInPriorCredentials.filter(function (credNotInPrior) { + let credentialsToAdd = credentialsNotInPriorCredentials.filter((credNotInPrior) => { let previousOverrides = _.get(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides', []); - return !previousOverrides.some(function (priorCred) { + return !previousOverrides.some((priorCred) => { return credNotInPrior.id === priorCred.id; }); }); @@ -151,8 +186,8 @@ export default ['$scope', 'TemplatesService', let credentialsToRemove = []; if (_.has(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides')) { - credentialsToRemove = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.previousOverrides.filter(function (priorCred) { - return !credentialsNotInPriorCredentials.some(function (credNotInPrior) { + credentialsToRemove = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.previousOverrides.filter((priorCred) => { + return !credentialsNotInPriorCredentials.some((credNotInPrior) => { return priorCred.id === credNotInPrior.id; }); }); @@ -181,7 +216,7 @@ export default ['$scope', 'TemplatesService', }); - let deletePromises = deletedNodeIds.map(function (nodeId) { + let deletePromises = deletedNodeIds.map((nodeId) => { return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); }); @@ -315,8 +350,8 @@ export default ['$scope', 'TemplatesService', }); $q.all(disassociatePromises) - .then(function () { - let credentialPromises = credentialRequests.map(function (request) { + .then(() => { + let credentialPromises = credentialRequests.map((request) => { return TemplatesService.postWorkflowNodeCredential({ id: request.id, data: request.data @@ -324,7 +359,7 @@ export default ['$scope', 'TemplatesService', }); return $q.all(associatePromises.concat(credentialPromises)) - .then(function () { + .then(() => { Wait('stop'); $scope.closeDialog(); }); @@ -339,12 +374,12 @@ export default ['$scope', 'TemplatesService', } else { - let deletePromises = deletedNodeIds.map(function (nodeId) { + let deletePromises = deletedNodeIds.map((nodeId) => { return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); }); $q.all(deletePromises) - .then(function () { + .then(() => { Wait('stop'); $scope.closeDialog(); $state.transitionTo('templates'); @@ -354,7 +389,7 @@ export default ['$scope', 'TemplatesService', /* ADD NODE FUNCTIONS */ - $scope.startAddNodeWithoutChild = function (parent) { + $scope.startAddNodeWithoutChild = (parent) => { if ($scope.nodeConfig) { $scope.cancelNodeForm(); } @@ -389,7 +424,7 @@ export default ['$scope', 'TemplatesService', $scope.formState.showNodeForm = true; }; - $scope.startAddNodeWithChild = function (link) { + $scope.startAddNodeWithChild = (link) => { if ($scope.nodeConfig) { $scope.cancelNodeForm(); } @@ -432,7 +467,7 @@ export default ['$scope', 'TemplatesService', $scope.formState.showNodeForm = true; }; - $scope.confirmNodeForm = function(selectedTemplate, promptData, edgeType) { + $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType) => { const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { if (selectedTemplate && edgeType && edgeType.value) { @@ -471,7 +506,7 @@ export default ['$scope', 'TemplatesService', $scope.$broadcast("refreshWorkflowChart"); }; - $scope.cancelNodeForm = function() { + $scope.cancelNodeForm = () => { const nodeId = $scope.nodeConfig.nodeId; if ($scope.nodeConfig.mode === "add") { // Remove the placeholder node from the array @@ -524,7 +559,7 @@ export default ['$scope', 'TemplatesService', /* EDIT NODE FUNCTIONS */ - $scope.startEditNode = function(nodeToEdit) { + $scope.startEditNode = (nodeToEdit) => { if ($scope.linkConfig) { $scope.cancelLinkForm(); } @@ -763,17 +798,17 @@ export default ['$scope', 'TemplatesService', /* DELETE NODE FUNCTIONS */ - $scope.startDeleteNode = function (nodeToDelete) { + $scope.startDeleteNode = (nodeToDelete) => { $scope.nodeToBeDeleted = nodeToDelete; $scope.deleteOverlayVisible = true; }; - $scope.cancelDeleteNode = function () { + $scope.cancelDeleteNode = () => { $scope.nodeToBeDeleted = null; $scope.deleteOverlayVisible = false; }; - $scope.confirmDeleteNode = function () { + $scope.confirmDeleteNode = () => { if ($scope.nodeToBeDeleted) { const nodeId = $scope.nodeToBeDeleted.id; @@ -857,73 +892,34 @@ export default ['$scope', 'TemplatesService', }; - $scope.toggleManualControls = function() { + $scope.toggleManualControls = () => { $scope.showManualControls = !$scope.showManualControls; }; - $scope.panChart = function (direction) { + $scope.panChart = (direction) => { $scope.$broadcast('panWorkflowChart', { direction: direction }); }; - $scope.zoomChart = function (zoom) { + $scope.zoomChart = (zoom) => { $scope.$broadcast('zoomWorkflowChart', { zoom: zoom }); }; - $scope.resetChart = function () { + $scope.resetChart = () => { $scope.$broadcast('resetWorkflowChart'); }; - $scope.workflowZoomed = function (zoom) { + $scope.workflowZoomed = (zoom) => { $scope.$broadcast('workflowZoomed', { zoom: zoom }); }; - $scope.zoomToFitChart = function () { + $scope.zoomToFitChart = () => { $scope.$broadcast('zoomToFitChart'); }; - - let allNodes = []; - let page = 1; - - let getNodes = function () { - Wait('start'); - TemplatesService.getWorkflowJobTemplateNodes($scope.workflowJobTemplateObj.id, page) - .then(function (data) { - for (var i = 0; i < data.data.results.length; i++) { - allNodes.push(data.data.results[i]); - } - if (data.data.next) { - // Get the next page - page++; - getNodes(); - } else { - let arrayOfLinksForChart = []; - let arrayOfNodesForChart = []; - - ({arrayOfNodesForChart, arrayOfLinksForChart, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); - - $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; - - Wait('stop'); - } - }, function ({ data, status, config }) { - Wait('stop'); - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER'), - msg: $scope.strings.get('error.CALL', { - path: `${config.url}`, - action: `${config.method}`, - status - }) - }); - }); - }; - - getNodes(); } ]; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 52c09a3f28..8ec220a933 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -74,7 +74,7 @@
{{strings.get('workflow_maker.TOTAL_TEMPLATES')}} - +
diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 7096bed720..1dc69bd790 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -5,9 +5,8 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, ParseVariableString, count, $state, i18n, WorkflowChartService, $filter, moment) { + let nodeRef; var runTimeElapsedTimer = null; - let nodeIdToChartNodeIdMapping = {}; - let chartNodeIdToIndexMapping = {}; var getLinks = function() { var getLink = function(key) { @@ -113,7 +112,6 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', function init() { // put initially resolved request data on scope $scope.workflow = workflowData; - $scope.workflow_nodes = workflowNodes; $scope.workflowOptions = workflowDataOptions.actions.GET; $scope.labels = jobLabels; $scope.showManualControls = false; @@ -173,7 +171,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', let arrayOfLinksForChart = []; let arrayOfNodesForChart = []; - ({arrayOfNodesForChart, arrayOfLinksForChart, chartNodeIdToIndexMapping, nodeIdToChartNodeIdMapping} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes)); + ({arrayOfNodesForChart, arrayOfLinksForChart, nodeRef} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes)); $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; } @@ -275,15 +273,12 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer); } - $scope.graphState.arrayOfNodesForChart[chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[data.workflow_node_id]]].job = { - id: data.unified_job_id, - status: data.status - }; - - $scope.workflow_nodes.forEach(node => { - if(parseInt(node.id) === parseInt(data.workflow_node_id)){ - node.summary_fields.job = { - status: data.status + $scope.graphState.arrayOfNodesForChart.forEach((node) => { + if (nodeRef[node.id] && nodeRef[node.id].originalNodeObject.id === data.workflow_node_id) { + node.job = { + id: data.unified_job_id, + status: data.status, + type: nodeRef[node.id].unifiedJobTemplate.unified_job_type }; } }); From f30f52a0a82173c9d0d8d3e078c4d11271c9f629 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Sun, 18 Nov 2018 11:25:03 -0500 Subject: [PATCH 62/99] handle missing unified job template in workflow * Workflow Node without unified_job_template is treated as a job marked as failure; when deciding what path to execute. * Remove optimization of marking dnr nodes due to it making the algorithm incorrect. --- awx/main/scheduler/dag_workflow.py | 59 ++++++++----------- .../tests/unit/scheduler/test_dag_workflow.py | 53 ++++++++++++----- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 0b7aeb7140..55316410b5 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -66,6 +66,10 @@ class WorkflowDAG(SimpleDAG): if p.do_not_run is True: continue + # job template relationship deleted, don't run the node and take the failure path + if p.do_not_run is False and not p.job and p.unified_job_template is None: + return True + # Node might run a job if p.do_not_run is False and not p.job: return False @@ -86,7 +90,7 @@ class WorkflowDAG(SimpleDAG): continue node_ids_visited.add(obj.id) - if obj.do_not_run is True: + if obj.do_not_run is True and obj.unified_job_template: continue if obj.job: @@ -96,6 +100,9 @@ class WorkflowDAG(SimpleDAG): elif obj.job.status == 'successful': nodes.extend(self.get_dependencies(obj, 'success_nodes') + self.get_dependencies(obj, 'always_nodes')) + elif obj.unified_job_template is None: + nodes.extend(self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) else: if self._are_relevant_parents_finished(n): nodes_found.append(n) @@ -127,16 +134,18 @@ class WorkflowDAG(SimpleDAG): failed_nodes = [] for node in self.nodes: obj = node['node_object'] - - if obj.unified_job_template is None: - return True, "Workflow job node {} related unified job template missing".format(obj.id) if obj.job and obj.job.status in ['failed', 'canceled', 'error']: failed_nodes.append(node) + elif obj.do_not_run is True and obj.unified_job_template is None: + failed_nodes.append(node) for node in failed_nodes: obj = node['node_object'] if (len(self.get_dependencies(obj, 'failure_nodes')) + len(self.get_dependencies(obj, 'always_nodes'))) == 0: - return True, "Workflow job node {} has a status of '{}' without an error handler path".format(obj.id, obj.job.status) + if obj.unified_job_template is None: + return True, "Workflow job node {} related unified job template missing and is without an error handle path".format(obj.id) + else: + return True, "Workflow job node {} has a status of '{}' without an error handler path".format(obj.id, obj.job.status) return False, None r''' @@ -151,6 +160,8 @@ class WorkflowDAG(SimpleDAG): ''' def _are_all_nodes_dnr_decided(self, workflow_nodes): for n in workflow_nodes: + if n.unified_job_template is None and n.do_not_run is False: + return False if n.do_not_run is False and not n.job: return False return True @@ -178,50 +189,30 @@ class WorkflowDAG(SimpleDAG): return False else: return False + elif p.unified_job_template is None: + if node in (self.get_dependencies(p, 'failure_nodes') + + self.get_dependencies(p, 'always_nodes')): + return False else: return False return True - r''' - Useful in a failure scenario. Will mark all nodes that might have run a job - and haven't already run a job as do_not_run=True - - Return an array of workflow nodes that were marked do_not_run = True - ''' - def _mark_all_remaining_nodes_dnr(self): - objs = [] - for node in self.nodes: - obj = node['node_object'] - if obj.do_not_run is False and not obj.job: - obj.do_not_run = True - objs.append(obj) - return objs - def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] - node_ids_visited = set() for index, n in enumerate(nodes): obj = n['node_object'] - if obj.id in node_ids_visited: - continue - node_ids_visited.add(obj.id) - ''' - Special case. On a workflow job template relaunch it's possible for - the job template associated with the job to have been deleted. If - this is the case, fail the workflow job and mark it done. - ''' - if obj.unified_job_template is None: - return self._mark_all_remaining_nodes_dnr() - - if obj.do_not_run is False and not obj.job and n not in root_nodes: + if obj.do_not_run is False and not obj.job: parent_nodes = filter(lambda n: not n.do_not_run, [p['node_object'] for p in self.get_dependents(obj)]) if self._are_all_nodes_dnr_decided(parent_nodes): - if self._should_mark_node_dnr(n, parent_nodes): + if obj.unified_job_template is None: + obj.do_not_run = True + nodes_marked_do_not_run.append(n) + elif n not in root_nodes and self._should_mark_node_dnr(n, parent_nodes): obj.do_not_run = True nodes_marked_do_not_run.append(n) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index 03a02d748c..de436bf15f 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -11,7 +11,7 @@ class Job(): class WorkflowNode(object): def __init__(self, id=None, job=None, do_not_run=False, unified_job_template=None): - self.id = id if id else uuid.uuid4() + self.id = id if id is not None else uuid.uuid4() self.job = job self.do_not_run = do_not_run self.unified_job_template = unified_job_template @@ -19,8 +19,12 @@ class WorkflowNode(object): @pytest.fixture def wf_node_generator(mocker): + pytest.count = 0 + def fn(**kwargs): - return WorkflowNode(**kwargs) + wfn = WorkflowNode(id=pytest.count, **kwargs) + pytest.count += 1 + return wfn return fn @@ -177,19 +181,6 @@ class TestIsWorkflowDone(): nodes[2].job = Job(status='failed') return (g, nodes) - def test_is_workflow_done(self, workflow_dag_2): - g = workflow_dag_2[0] - - assert g.is_workflow_done() is False - - def test_is_workflow_done_failed(self, workflow_dag_failed): - (g, nodes) = workflow_dag_failed - - assert g.is_workflow_done() is True - assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[2].id)) - - -class TestHasWorkflowFailed(): @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() @@ -207,6 +198,38 @@ class TestHasWorkflowFailed(): nodes[0].job.status = 'failed' return (g, nodes) + def test_done(self, workflow_dag_2): + g = workflow_dag_2[0] + + assert g.is_workflow_done() is False + + def test_workflow_done_and_failed(self, workflow_dag_failed): + (g, nodes) = workflow_dag_failed + + assert g.is_workflow_done() is True + assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[2].id)) + + def test_is_workflow_done_no_unified_job_tempalte_end(self, workflow_dag_failed): + (g, nodes) = workflow_dag_failed + + nodes[2].unified_job_template = None + + assert g.is_workflow_done() is True + assert g.has_workflow_failed() == \ + (True, "Workflow job node {} related unified job template missing" + " and is without an error handle path".format(nodes[2].id)) + + def test_is_workflow_done_no_unified_job_tempalte_begin(self, workflow_dag_1): + (g, nodes) = workflow_dag_1 + + nodes[0].unified_job_template = None + g.mark_dnr_nodes() + + assert g.is_workflow_done() is True + assert g.has_workflow_failed() == \ + (True, "Workflow job node {} related unified job template missing" + " and is without an error handle path".format(nodes[0].id)) + def test_canceled_should_fail(self, workflow_dag_canceled): (g, nodes) = workflow_dag_canceled From d1aa52a2a62fa5393a0890ecf47655f8e437b457 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Sun, 18 Nov 2018 12:17:26 -0500 Subject: [PATCH 63/99] fix up mark dnr logic --- awx/main/scheduler/dag_workflow.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 55316410b5..b405262218 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -193,6 +193,8 @@ class WorkflowDAG(SimpleDAG): if node in (self.get_dependencies(p, 'failure_nodes') + self.get_dependencies(p, 'always_nodes')): return False + elif p.do_not_run is True: + pass else: return False return True @@ -202,19 +204,18 @@ class WorkflowDAG(SimpleDAG): nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] - for index, n in enumerate(nodes): - obj = n['node_object'] + for index, node in enumerate(nodes): + obj = node['node_object'] if obj.do_not_run is False and not obj.job: - parent_nodes = filter(lambda n: not n.do_not_run, - [p['node_object'] for p in self.get_dependents(obj)]) + parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] if self._are_all_nodes_dnr_decided(parent_nodes): if obj.unified_job_template is None: obj.do_not_run = True - nodes_marked_do_not_run.append(n) - elif n not in root_nodes and self._should_mark_node_dnr(n, parent_nodes): + nodes_marked_do_not_run.append(node) + elif node not in root_nodes and self._should_mark_node_dnr(node, parent_nodes): obj.do_not_run = True - nodes_marked_do_not_run.append(n) + nodes_marked_do_not_run.append(node) nodes.extend(self.get_dependencies(obj, 'success_nodes') + self.get_dependencies(obj, 'failure_nodes') + From 4c9a1d6b909fb57b9dce401d75e0e45fa7b2b6ee Mon Sep 17 00:00:00 2001 From: chris meyers Date: Sun, 18 Nov 2018 14:32:56 -0500 Subject: [PATCH 64/99] optimize mark dnr nodes algorithm * Compute largest depth of each node and traverse graph by depth. This allows us to check a node once, and only once, to determine if it needs to be marked for do not run. --- awx/main/scheduler/dag_simple.py | 23 +++++++++++++++++++++++ awx/main/scheduler/dag_workflow.py | 14 ++++---------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index fd54b6a34f..ecd4da28ac 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,8 +1,11 @@ + class SimpleDAG(object): ''' A simple implementation of a directed acyclic graph ''' def __init__(self): + # Depth + self.depth = [set([])] self.nodes = [] self.root_nodes = set([]) @@ -99,6 +102,8 @@ class SimpleDAG(object): gv_file.close() def add_node(self, obj, metadata=None): + if not metadata: + metadata = dict() if self.find_ord(obj) is None: ''' Assume node is a root node until a child is added @@ -109,6 +114,11 @@ class SimpleDAG(object): entry = dict(node_object=obj, metadata=metadata) self.nodes.append(entry) + # Depth + metadata['depth'] = 0 + self.depth[0].add(node_index) + return node_index + def add_edge(self, from_obj, to_obj, label): from_obj_ord = self.find_ord(from_obj) to_obj_ord = self.find_ord(to_obj) @@ -133,6 +143,19 @@ class SimpleDAG(object): self.node_from_edges_by_label[label][from_obj_ord].append(to_obj_ord) self.node_to_edges_by_label[label][to_obj_ord].append(from_obj_ord) + # Depth + parent_depth = self.nodes[from_obj_ord]['metadata']['depth'] + current_depth = self.nodes[to_obj_ord]['metadata']['depth'] + if parent_depth >= current_depth: + if len(self.depth) <= parent_depth + 1: + self.depth.append(set([])) + + self.nodes[to_obj_ord]['metadata']['depth'] = parent_depth + 1 + + self.depth[current_depth].remove(to_obj_ord) + self.depth[parent_depth + 1].add(to_obj_ord) + + def find_ord(self, obj): return self.node_obj_to_node_index.get(obj, None) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index b405262218..315fbde5ec 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,6 +1,5 @@ # Python -import copy from awx.main.models import ( WorkflowJobTemplateNode, WorkflowJobNode, @@ -201,13 +200,12 @@ class WorkflowDAG(SimpleDAG): def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() - nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] - for index, node in enumerate(nodes): - obj = node['node_object'] - - if obj.do_not_run is False and not obj.job: + for node_indexes in self.depth: + for node_index in node_indexes: + node = self.nodes[node_index] + obj = node['node_object'] parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] if self._are_all_nodes_dnr_decided(parent_nodes): if obj.unified_job_template is None: @@ -216,8 +214,4 @@ class WorkflowDAG(SimpleDAG): elif node not in root_nodes and self._should_mark_node_dnr(node, parent_nodes): obj.do_not_run = True nodes_marked_do_not_run.append(node) - - nodes.extend(self.get_dependencies(obj, 'success_nodes') + - self.get_dependencies(obj, 'failure_nodes') + - self.get_dependencies(obj, 'always_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From 3c510e6344297abbba12b8b5a204ecb3b46e36d6 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 19 Nov 2018 10:39:57 -0500 Subject: [PATCH 65/99] Fixed bug where root link became clickable. Fix workflow key on results page. --- .../workflow-chart.directive.js | 126 +++++++++--------- .../workflow-controls.directive.js | 31 ++--- .../workflow-maker.controller.js | 8 +- .../workflow-results.controller.js | 3 + 4 files changed, 83 insertions(+), 85 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 2d3e21cf0b..2631b8e5f8 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -40,42 +40,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge scope.dimensionsSet = false; - $timeout(function(){ - let dimensions = calcAvailableScreenSpace(); - - windowHeight = dimensions.height; - windowWidth = dimensions.width; - - $('.WorkflowMaker-chart').css("height", windowHeight); - - scope.dimensionsSet = true; - - init(); - }); - - function init() { - line = d3.svg.line() - .x(function (d) { - return d.x; - }) - .y(function (d) { - return d.y; - }); - - zoomObj = d3.behavior.zoom().scaleExtent([0.1, 2]); - - baseSvg = d3.select(element[0]).append("svg") - .attr("class", "WorkflowChart-svg") - .call(zoomObj - .on("zoom", naturalZoom) - ); - - svgGroup = baseSvg.append("g") - .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); - } - - function calcAvailableScreenSpace() { + const calcAvailableScreenSpace = () => { let dimensions = {}; if(scope.mode !== 'details') { @@ -97,15 +62,15 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } return dimensions; - } + }; // Dagre is going to shift the root node around as nodes are added/removed // This function ensures that the user doesn't experience that - let normalizeY = ((y) => { + const normalizeY = ((y) => { return y - nodePositionMap[1].y; }); - function lineData(d) { + const lineData = (d) => { let sourceX = nodePositionMap[d.source.id].x + (nodePositionMap[d.source.id].width); let sourceY = normalizeY(nodePositionMap[d.source.id].y) + (nodePositionMap[d.source.id].height/2); @@ -132,21 +97,21 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge ]; return line(points); - } + }; // TODO: this function is hacky and we need to come up with a better solution // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 - function wrap(text) { + const wrap = (text) => { if(text && text.length > maxNodeTextLength) { return text.substring(0,maxNodeTextLength) + '...'; } else { return text; } - } + }; - function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { - var retval; + const rounded_rect = (x, y, w, h, r, tl, tr, bl, br) => { + let retval; retval = "M" + (x + r) + "," + y; retval += "h" + (w - 2*r); if (tr) { retval += "a" + r + "," + r + " 0 0 1 " + r + "," + r; } @@ -162,10 +127,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge else { retval += "v" + -r; retval += "h" + r; } retval += "z"; return retval; - } + }; // This is the zoom function called by using the mousewheel/click and drag - function naturalZoom() { + const naturalZoom = () => { let scale = d3.event.scale, translation = d3.event.translate; @@ -176,10 +141,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge scope.workflowZoomed({ zoom: scale }); - } + }; // This is the zoom that gets called when the user interacts with the manual zoom controls - function manualZoom(zoom) { + const manualZoom = (zoom) => { let scale = zoom / 100, translation = zoomObj.translate(), origZoom = zoomObj.scale(), @@ -191,9 +156,9 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge svgGroup.attr("transform", "translate(" + [translateX, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); zoomObj.scale(scale); zoomObj.translate([translateX, translateY]); - } + }; - function manualPan(direction) { + const manualPan = (direction) => { let scale = zoomObj.scale(), distance = 150 * scale, translateX, @@ -208,16 +173,16 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)) + ")scale(" + scale + ")"); zoomObj.translate([translateX, translateY]); - } + }; - function resetZoomAndPan() { + const resetZoomAndPan = () => { svgGroup.attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); // Update the zoomObj zoomObj.scale(1); zoomObj.translate([0,0]); - } + }; - function zoomToFitChart() { + const zoomToFitChart = () => { let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), availableScreenSpace = calcAvailableScreenSpace(), currentZoomValue = zoomObj.scale(), @@ -236,9 +201,9 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge svgGroup.attr("transform", "translate(0," + (windowHeight/2 - (nodeH*scaleToFit/2)) + ")scale(" + scaleToFit + ")"); zoomObj.translate([0, windowHeight/2 - (nodeH*scaleToFit/2) - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); - } + }; - function update() { + const updateGraph = () => { if(scope.dimensionsSet) { const buildLinkTooltip = (d) => { let sourceNode = d3.select(`#node-${d.source.id}`); @@ -315,7 +280,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge }); }; - var g = new dagre.graphlib.Graph(); + let g = new dagre.graphlib.Graph(); g.setGraph({rankdir: 'LR', nodesep: 30, ranksep: 120}); @@ -780,7 +745,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .each(function(d) { if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { // Pulse the circle - var circle = d3.select(this); + let circle = d3.select(this); (function repeat() { circle = circle.transition() .duration(2000) @@ -1267,11 +1232,11 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge if(scope.dimensionsSet) { scope.watchDimensionsSet(); scope.watchDimensionsSet = null; - update(); + updateGraph(); } }); } - } + }; function add_node_without_child() { this.on("click", function(d) { @@ -1367,7 +1332,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge scope.$on('refreshWorkflowChart', function(){ if(scope.graphState) { - update(); + updateGraph(); } }); @@ -1387,12 +1352,10 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge zoomToFitChart(); }); - let clearWatchgraphState = scope.$watch('graphState.arrayOfNodesForChart', function(newVal) { + let clearWatchGraphState = scope.$watch('graphState.arrayOfNodesForChart', function(newVal) { if(newVal) { - // scope.graphState.arrayOfNodesForChart - - update(); - clearWatchgraphState(); + updateGraph(); + clearWatchGraphState(); } }); @@ -1407,6 +1370,37 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge angular.element($window).off('resize', onResize); } + $timeout(() => { + let dimensions = calcAvailableScreenSpace(); + + windowHeight = dimensions.height; + windowWidth = dimensions.width; + + $('.WorkflowMaker-chart').css("height", windowHeight); + + scope.dimensionsSet = true; + + line = d3.svg.line() + .x(function (d) { + return d.x; + }) + .y(function (d) { + return d.y; + }); + + zoomObj = d3.behavior.zoom().scaleExtent([0.1, 2]); + + baseSvg = d3.select(element[0]).append("svg") + .attr("class", "WorkflowChart-svg") + .call(zoomObj + .on("zoom", naturalZoom) + ); + + svgGroup = baseSvg.append("g") + .attr("id", "aw-workflow-chart-g") + .attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); + }); + if(scope.mode === 'details') { angular.element($window).on('resize', onResize); scope.$on('$destroy', cleanUpResize); diff --git a/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js b/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js index 5e2833c7f0..f7e74e6e8b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-controls/workflow-controls.directive.js @@ -17,22 +17,6 @@ export default ['templateUrl', restrict: 'E', link: function(scope) { - function init() { - scope.zoom = 100; - $( "#slider" ).slider({ - value:100, - min: 10, - max: 200, - step: 10, - slide: function( event, ui ) { - scope.zoom = ui.value; - scope.zoomChart({ - zoom: scope.zoom - }); - } - }); - } - scope.pan = function(direction) { scope.panChart({ direction: direction @@ -70,7 +54,20 @@ export default ['templateUrl', $("#slider").slider('value',scope.zoom); }); - init(); + scope.zoom = 100; + + $( "#slider" ).slider({ + value:100, + min: 10, + max: 200, + step: 10, + slide: function( event, ui ) { + scope.zoom = ui.value; + scope.zoomChart({ + zoom: scope.zoom + }); + } + }); } }; } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 7ff6d67536..75976ee70a 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -36,7 +36,7 @@ export default ['$scope', 'TemplatesService', Wait('start'); TemplatesService.getWorkflowJobTemplateNodes($scope.workflowJobTemplateObj.id, page) .then(({data}) => { - for (var i = 0; i < data.results.length; i++) { + for (let i = 0; i < data.results.length; i++) { allNodes.push(data.results[i]); } if (data.next) { @@ -538,11 +538,15 @@ export default ['$scope', 'TemplatesService', // Add the new links parents.forEach((parentId) => { children.forEach((child) => { + let source = { + id: parentId + }; if (parentId === 1) { child.edgeType = "always"; + source.isStartNode = true; } $scope.graphState.arrayOfLinksForChart.push({ - source: {id: parentId}, + source, target: {id: child.id}, edgeType: child.edgeType }); diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 1dc69bd790..9f7ad79bb1 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -8,6 +8,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', let nodeRef; var runTimeElapsedTimer = null; + $scope.toggleKey = () => $scope.showKey = !$scope.showKey; + $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; + var getLinks = function() { var getLink = function(key) { if(key === 'schedule') { From cfa098479e012ec9f8c4f8b640de73be9e25e3e7 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 19 Nov 2018 10:54:42 -0500 Subject: [PATCH 66/99] Revert "optimize mark dnr nodes algorithm" This reverts commit 6372c52772e26f64f3b4fd227ffb5e401d3688e1. --- awx/main/scheduler/dag_simple.py | 23 ----------------------- awx/main/scheduler/dag_workflow.py | 14 ++++++++++---- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index ecd4da28ac..fd54b6a34f 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,11 +1,8 @@ - class SimpleDAG(object): ''' A simple implementation of a directed acyclic graph ''' def __init__(self): - # Depth - self.depth = [set([])] self.nodes = [] self.root_nodes = set([]) @@ -102,8 +99,6 @@ class SimpleDAG(object): gv_file.close() def add_node(self, obj, metadata=None): - if not metadata: - metadata = dict() if self.find_ord(obj) is None: ''' Assume node is a root node until a child is added @@ -114,11 +109,6 @@ class SimpleDAG(object): entry = dict(node_object=obj, metadata=metadata) self.nodes.append(entry) - # Depth - metadata['depth'] = 0 - self.depth[0].add(node_index) - return node_index - def add_edge(self, from_obj, to_obj, label): from_obj_ord = self.find_ord(from_obj) to_obj_ord = self.find_ord(to_obj) @@ -143,19 +133,6 @@ class SimpleDAG(object): self.node_from_edges_by_label[label][from_obj_ord].append(to_obj_ord) self.node_to_edges_by_label[label][to_obj_ord].append(from_obj_ord) - # Depth - parent_depth = self.nodes[from_obj_ord]['metadata']['depth'] - current_depth = self.nodes[to_obj_ord]['metadata']['depth'] - if parent_depth >= current_depth: - if len(self.depth) <= parent_depth + 1: - self.depth.append(set([])) - - self.nodes[to_obj_ord]['metadata']['depth'] = parent_depth + 1 - - self.depth[current_depth].remove(to_obj_ord) - self.depth[parent_depth + 1].add(to_obj_ord) - - def find_ord(self, obj): return self.node_obj_to_node_index.get(obj, None) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 315fbde5ec..b405262218 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,5 +1,6 @@ # Python +import copy from awx.main.models import ( WorkflowJobTemplateNode, WorkflowJobNode, @@ -200,12 +201,13 @@ class WorkflowDAG(SimpleDAG): def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() + nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] - for node_indexes in self.depth: - for node_index in node_indexes: - node = self.nodes[node_index] - obj = node['node_object'] + for index, node in enumerate(nodes): + obj = node['node_object'] + + if obj.do_not_run is False and not obj.job: parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] if self._are_all_nodes_dnr_decided(parent_nodes): if obj.unified_job_template is None: @@ -214,4 +216,8 @@ class WorkflowDAG(SimpleDAG): elif node not in root_nodes and self._should_mark_node_dnr(node, parent_nodes): obj.do_not_run = True nodes_marked_do_not_run.append(node) + + nodes.extend(self.get_dependencies(obj, 'success_nodes') + + self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From 7b087d4a6c3a9b3f1df275558305ac58e0738ced Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 19 Nov 2018 11:32:13 -0500 Subject: [PATCH 67/99] loop over dnr nodes by topological sort * Perform topological sort on graph nodes before looping over them to mark do not run. This guarantees that parent nodes will be processed before calling dependent child nodes. The complexity of the sorting is N. The complexity of marking the the nodes is N*V --- awx/main/scheduler/dag_simple.py | 25 +++++++++++++++++++++++++ awx/main/scheduler/dag_workflow.py | 7 +------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index fd54b6a34f..2ebda0375b 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,3 +1,5 @@ +from collections import deque + class SimpleDAG(object): ''' A simple implementation of a directed acyclic graph ''' @@ -198,3 +200,26 @@ class SimpleDAG(object): node_objs_visited.add(node_obj) path.discard(node_obj) return res + + def sort_nodes_topological(self): + nodes_sorted = deque() + obj_ids_processed = set([]) + + def visit(node): + obj = node['node_object'] + if obj.id in obj_ids_processed: + return + + for child in self.get_dependencies(obj): + visit(child) + obj_ids_processed.add(obj.id) + nodes_sorted.appendleft(node) + + for node in self.nodes: + obj = node['node_object'] + if obj.id in obj_ids_processed: + continue + + visit(node) + + return nodes_sorted diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index b405262218..41aba609b7 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,6 +1,5 @@ # Python -import copy from awx.main.models import ( WorkflowJobTemplateNode, WorkflowJobNode, @@ -201,10 +200,9 @@ class WorkflowDAG(SimpleDAG): def mark_dnr_nodes(self): root_nodes = self.get_root_nodes() - nodes = copy.copy(root_nodes) nodes_marked_do_not_run = [] - for index, node in enumerate(nodes): + for node in self.sort_nodes_topological(): obj = node['node_object'] if obj.do_not_run is False and not obj.job: @@ -217,7 +215,4 @@ class WorkflowDAG(SimpleDAG): obj.do_not_run = True nodes_marked_do_not_run.append(node) - nodes.extend(self.get_dependencies(obj, 'success_nodes') + - self.get_dependencies(obj, 'failure_nodes') + - self.get_dependencies(obj, 'always_nodes')) return [n['node_object'] for n in nodes_marked_do_not_run] From a804c854bf6e12f3af30d3bcba9098bac05e5305 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 19 Nov 2018 13:48:50 -0500 Subject: [PATCH 68/99] Fix test failures and jshint errors --- awx/ui/.jshintrc | 3 +- .../workflow-maker.controller.js | 6 -- .../workflow-maker.partial.html | 4 +- .../workflow-results.controller-test.js | 7 +-- .../workflow-maker.controller-test.js | 62 ------------------- 5 files changed, 7 insertions(+), 75 deletions(-) delete mode 100644 awx/ui/test/spec/workflows/workflow-maker.controller-test.js diff --git a/awx/ui/.jshintrc b/awx/ui/.jshintrc index c8075a8ba8..a2bb56723e 100644 --- a/awx/ui/.jshintrc +++ b/awx/ui/.jshintrc @@ -34,7 +34,8 @@ "describe": false, "moment": false, "spyOn": false, - "jasmine": false + "jasmine": false, + "dagre": false }, "strict": false, "quotmark": false, diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 75976ee70a..5c8d4e6611 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -68,12 +68,6 @@ export default ['$scope', 'TemplatesService', getNodes(); - $scope.closeWorkflowMaker = () => { - // Revert the data to the master which was created when the dialog was opened - $scope.graphState.nodeTree = angular.copy($scope.graphStateMaster); - $scope.closeDialog(); - }; - $scope.saveWorkflowMaker = () => { Wait('start'); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 8ec220a933..e44befc510 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -28,7 +28,7 @@
{{strings.get('workflow_maker.TITLE')}} | {{ workflowJobTemplateObj.name }}
-
@@ -106,7 +106,7 @@
- +
diff --git a/awx/ui/test/spec/workflow--results/workflow-results.controller-test.js b/awx/ui/test/spec/workflow--results/workflow-results.controller-test.js index 34401f6d78..15c68045ce 100644 --- a/awx/ui/test/spec/workflow--results/workflow-results.controller-test.js +++ b/awx/ui/test/spec/workflow--results/workflow-results.controller-test.js @@ -30,11 +30,11 @@ describe('Controller: workflowResults', () => { $provide.value('ParseVariableString', function() {}); $provide.value('i18n', { '_': (a) => { return a; } }); $provide.provider('$stateProvider', { '$get': function() { return function() {}; } }); - $provide.service('WorkflowService', function($q) { + $provide.service('WorkflowChartService', function($q) { return { - buildTree: function() { + generateArraysOfNodesAndLinks: function() { var deferred = $q.defer(); - deferred.resolve(treeData); + deferred.resolve(); return deferred.promise; } }; @@ -46,7 +46,6 @@ describe('Controller: workflowResults', () => { $rootScope = _$rootScope_; workflowResultsService = _workflowResultsService_; $interval = _$interval_; - })); describe('elapsed timer', () => { diff --git a/awx/ui/test/spec/workflows/workflow-maker.controller-test.js b/awx/ui/test/spec/workflows/workflow-maker.controller-test.js deleted file mode 100644 index 58e5db8abf..0000000000 --- a/awx/ui/test/spec/workflows/workflow-maker.controller-test.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict'; - -describe('Controller: WorkflowMaker', () => { - // Setup - let scope, - WorkflowMakerController, - TemplatesService, - q, - getWorkflowJobTemplateNodesDeferred; - - beforeEach(angular.mock.module('awApp')); - beforeEach(angular.mock.module('templates', () => { - - TemplatesService = { - getWorkflowJobTemplateNodes: function(){ - return angular.noop; - } - }; - - })); - - beforeEach(angular.mock.inject( ($rootScope, $controller, $q) => { - scope = $rootScope.$new(); - scope.closeDialog = jasmine.createSpy(); - scope.treeData = { - data: { - id: 1, - canDelete: false, - canEdit: false, - canAddTo: true, - isStartNode: true, - unifiedJobTemplate: { - name: "Workflow Launch" - }, - children: [], - deletedNodes: [], - totalNodes: 0 - }, - nextIndex: 2 - }; - scope.workflowJobTemplateObj = { - id: 1 - }; - q = $q; - getWorkflowJobTemplateNodesDeferred = q.defer(); - TemplatesService.getWorkflowJobTemplateNodes = jasmine.createSpy('getWorkflowJobTemplateNodes').and.returnValue(getWorkflowJobTemplateNodesDeferred.promise); - WorkflowMakerController = $controller('WorkflowMakerController', { - $scope: scope, - TemplatesService: TemplatesService - }); - })); - - describe('scope.closeWorkflowMaker()', () => { - - it('should close the dialog', ()=>{ - scope.closeWorkflowMaker(); - expect(scope.closeDialog).toHaveBeenCalled(); - }); - - }); - -}); From 623cf5476602390c12bee4155b092e7caa0594de Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 19 Nov 2018 13:56:51 -0500 Subject: [PATCH 69/99] Added dagre and graphlib licenses --- docs/licenses/ui/dagre.txt | 19 +++++++++++++++++++ docs/licenses/ui/graphlib.txt | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/licenses/ui/dagre.txt create mode 100644 docs/licenses/ui/graphlib.txt diff --git a/docs/licenses/ui/dagre.txt b/docs/licenses/ui/dagre.txt new file mode 100644 index 0000000000..7d7dd94248 --- /dev/null +++ b/docs/licenses/ui/dagre.txt @@ -0,0 +1,19 @@ +Copyright (c) 2012-2014 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/licenses/ui/graphlib.txt b/docs/licenses/ui/graphlib.txt new file mode 100644 index 0000000000..7d7dd94248 --- /dev/null +++ b/docs/licenses/ui/graphlib.txt @@ -0,0 +1,19 @@ +Copyright (c) 2012-2014 Chris Pettitt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. From 56885a5da152c8eee00cbafd886e16cf68d1c1fd Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 19 Nov 2018 14:43:34 -0500 Subject: [PATCH 70/99] Remove reference to isStartNode and just check the id of the node to determine if it's our start node or not --- .../workflow-chart.directive.js | 24 +++++++++---------- .../workflow-chart/workflow-chart.service.js | 2 -- .../workflow-maker.controller.js | 1 - 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 2631b8e5f8..2a50dccb18 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -454,7 +454,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge if( d.edgeType !== 'placeholder' && !scope.graphState.isLinkMode && - !d.source.isStartNode && + d.source.id !== 1 && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details' @@ -467,7 +467,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }) .on("mouseout", function(d){ - if(!d.source.isStartNode && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + if(d.source.id !== 1 && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-linkHovering", false); @@ -486,7 +486,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge if( d.edgeType !== 'placeholder' && !scope.graphState.isLinkMode && - !d.source.isStartNode && + d.source.id !== 1 && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details' @@ -499,7 +499,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge } }) .on("mouseleave", function(d){ - if(!d.source.isStartNode && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { + if(d.source.id !== 1 && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details') { $(`#aw-workflow-chart-g`).prepend($(`#link-${d.source.id}-${d.target.id}`)); d3.select("#link-" + d.source.id + "-" + d.target.id) .classed("WorkflowChart-linkHovering", false); @@ -796,7 +796,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge nodeEnter.each(function(d) { let thisNode = d3.select(this); - if(d.isStartNode && scope.mode === 'details') { + if(d.id === 1 && scope.mode === 'details') { // Overwrite the default root height and width and replace it with a small blue square rootW = 25; rootH = 25; @@ -809,7 +809,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("fill", "#337ab7") .attr("class", "WorkflowChart-rootNode"); } - else if(d.isStartNode && scope.mode !== 'details') { + else if(d.id === 1 && scope.mode !== 'details') { thisNode.append("rect") .attr("width", rootW) .attr("height", rootH) @@ -951,7 +951,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("class", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }) .call(node_click) .on("mouseover", function(d) { - if(!d.isStartNode) { + if(d.id !== 1) { $(`#node-${d.id}`).appendTo(`#aw-workflow-chart-g`); let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; if(resourceName && resourceName.length > maxNodeTextLength) { @@ -1016,7 +1016,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .on("mouseout", function(d){ $('.WorkflowChart-tooltip').remove(); $('.WorkflowChart-potentialLink').remove(); - if(!d.isStartNode) { + if(d.id !== 1) { d3.select("#node-" + d.id) .classed("WorkflowChart-nodeHovering", false); } @@ -1122,7 +1122,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .attr("cy", nodeH) .attr("r", 10) .attr("class", "WorkflowChart-nodeRemoveCircle") - .style("display", function(d) { return (d.isStartNode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -1144,7 +1144,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge .size(60) .type("cross") ) - .style("display", function(d) { return (d.isStartNode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .style("display", function(d) { return (d.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) .call(remove_node) .on("mouseover", function(d) { d3.select("#node-" + d.id) @@ -1260,7 +1260,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function remove_node() { this.on("click", function(d) { - if(!d.isStartNode && !scope.readOnly && !scope.graphState.isLinkMode) { + if(d.id !== 1 && !scope.readOnly && !scope.graphState.isLinkMode) { scope.deleteNode({ nodeToDelete: d }); @@ -1288,7 +1288,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge function edit_link() { this.on("click", function(d) { - if(!scope.graphState.isLinkMode && !d.source.isStartNode && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details'){ + if(!scope.graphState.isLinkMode && d.source.id !== 1 && d.source.id !== scope.graphState.nodeBeingAdded && d.target.id !== scope.graphState.nodeBeingAdded && scope.mode !== 'details'){ scope.editLink({ linkToEdit: d }); diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js index 76f9e6e2d6..beec159e37 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -10,9 +10,7 @@ export default [function(){ let nodeIdCounter = 1; let arrayOfNodesForChart = [ { - index: 0, id: nodeIdCounter, - isStartNode: true, unifiedJobTemplate: { name: "START" } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 5c8d4e6611..2dd983598a 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -537,7 +537,6 @@ export default ['$scope', 'TemplatesService', }; if (parentId === 1) { child.edgeType = "always"; - source.isStartNode = true; } $scope.graphState.arrayOfLinksForChart.push({ source, From febf051748b7af93a4dc2a4b74d3a10fc32710ae Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 19 Nov 2018 16:13:20 -0500 Subject: [PATCH 71/99] do not mark ujt None nodes dnr * Leave workflow nodes with no related unified job template nodes do_not_run = False. If we mark it True, we can't differentiate between the actual want to not take that path vs. do not run this because I do not have a valid related unified job template. --- awx/main/scheduler/dag_workflow.py | 102 +++++++++--------- .../tests/unit/scheduler/test_dag_workflow.py | 36 ++++--- 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 41aba609b7..9c3cb3cc4e 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,4 +1,7 @@ +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_text + # Python from awx.main.models import ( WorkflowJobTemplateNode, @@ -50,61 +53,37 @@ class WorkflowDAG(SimpleDAG): for edge in always_nodes: self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'always_nodes') - r''' - Determine if all, relevant, parents node are finished. - Relevant parents are parents that are marked do_not_run False. - - :param node: a node entry from SimpleDag.nodes (i.e. a dict with property ['node_object'] - - Return a boolean - ''' def _are_relevant_parents_finished(self, node): obj = node['node_object'] parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] for p in parent_nodes: if p.do_not_run is True: continue - - # job template relationship deleted, don't run the node and take the failure path - if p.do_not_run is False and not p.job and p.unified_job_template is None: - return True - + elif p.unified_job_template is None: + continue # Node might run a job - if p.do_not_run is False and not p.job: + elif not p.job: return False - # Node decidedly got a job; check if job is done - if p.job and p.job.status not in ['successful', 'failed', 'error', 'canceled']: + elif p.job and p.job.status not in ['successful', 'failed', 'error', 'canceled']: return False return True def bfs_nodes_to_run(self): - nodes = self.get_root_nodes() nodes_found = [] - node_ids_visited = set() - for index, n in enumerate(nodes): - obj = n['node_object'] - if obj.id in node_ids_visited: + for node in self.sort_nodes_topological(): + obj = node['node_object'] + if obj.do_not_run is True: continue - node_ids_visited.add(obj.id) - - if obj.do_not_run is True and obj.unified_job_template: + elif obj.job: continue - - if obj.job: - if obj.job.status in ['failed', 'error', 'canceled']: - nodes.extend(self.get_dependencies(obj, 'failure_nodes') + - self.get_dependencies(obj, 'always_nodes')) - elif obj.job.status == 'successful': - nodes.extend(self.get_dependencies(obj, 'success_nodes') + - self.get_dependencies(obj, 'always_nodes')) elif obj.unified_job_template is None: - nodes.extend(self.get_dependencies(obj, 'failure_nodes') + - self.get_dependencies(obj, 'always_nodes')) - else: - if self._are_relevant_parents_finished(n): - nodes_found.append(n) + continue + + if self._are_relevant_parents_finished(node): + nodes_found.append(node) + return [n['node_object'] for n in nodes_found] def cancel_node_jobs(self): @@ -123,7 +102,7 @@ class WorkflowDAG(SimpleDAG): def is_workflow_done(self): for node in self.nodes: obj = node['node_object'] - if obj.do_not_run is False and not obj.job: + if obj.do_not_run is False and not obj.job and obj.unified_job_template: return False elif obj.job and obj.job.status not in ['successful', 'failed', 'canceled', 'error']: return False @@ -131,20 +110,40 @@ class WorkflowDAG(SimpleDAG): def has_workflow_failed(self): failed_nodes = [] + res = False + failed_path_nodes_id_status = [] + failed_unified_job_template_node_ids = [] + for node in self.nodes: obj = node['node_object'] - if obj.job and obj.job.status in ['failed', 'canceled', 'error']: + if obj.do_not_run is False and obj.unified_job_template is None: failed_nodes.append(node) - elif obj.do_not_run is True and obj.unified_job_template is None: + elif obj.job and obj.job.status in ['failed', 'canceled', 'error']: failed_nodes.append(node) + for node in failed_nodes: obj = node['node_object'] if (len(self.get_dependencies(obj, 'failure_nodes')) + len(self.get_dependencies(obj, 'always_nodes'))) == 0: if obj.unified_job_template is None: - return True, "Workflow job node {} related unified job template missing and is without an error handle path".format(obj.id) + res = True + failed_unified_job_template_node_ids.append(str(obj.id)) else: - return True, "Workflow job node {} has a status of '{}' without an error handler path".format(obj.id, obj.job.status) + res = True + failed_path_nodes_id_status.append((str(obj.id), obj.job.status)) + + if res is True: + s = _("No error handle path for workflow job node(s) [{node_status}] workflow job " + "node(s) missing unified job template and error handle path [{no_ufjt}].") + parms = { + 'node_status': '', + 'no_ufjt': '', + } + if len(failed_path_nodes_id_status) > 0: + parms['node_status'] = ",".join(["({},{})".format(id, status) for id, status in failed_path_nodes_id_status]) + if len(failed_unified_job_template_node_ids) > 0: + parms['no_ufjt'] = ",".join(failed_unified_job_template_node_ids) + return True, smart_text(s.format(**parms)) return False, None r''' @@ -159,9 +158,7 @@ class WorkflowDAG(SimpleDAG): ''' def _are_all_nodes_dnr_decided(self, workflow_nodes): for n in workflow_nodes: - if n.unified_job_template is None and n.do_not_run is False: - return False - if n.do_not_run is False and not n.job: + if n.do_not_run is False and not n.job and n.unified_job_template: return False return True @@ -177,7 +174,9 @@ class WorkflowDAG(SimpleDAG): ''' def _should_mark_node_dnr(self, node, parent_nodes): for p in parent_nodes: - if p.job: + if p.do_not_run is True: + pass + elif p.job: if p.job.status == 'successful': if node in (self.get_dependencies(p, 'success_nodes') + self.get_dependencies(p, 'always_nodes')): @@ -188,12 +187,10 @@ class WorkflowDAG(SimpleDAG): return False else: return False - elif p.unified_job_template is None: + elif p.do_not_run is False and p.unified_job_template is None: if node in (self.get_dependencies(p, 'failure_nodes') + self.get_dependencies(p, 'always_nodes')): return False - elif p.do_not_run is True: - pass else: return False return True @@ -205,13 +202,10 @@ class WorkflowDAG(SimpleDAG): for node in self.sort_nodes_topological(): obj = node['node_object'] - if obj.do_not_run is False and not obj.job: + if obj.do_not_run is False and not obj.job and node not in root_nodes: parent_nodes = [p['node_object'] for p in self.get_dependents(obj)] if self._are_all_nodes_dnr_decided(parent_nodes): - if obj.unified_job_template is None: - obj.do_not_run = True - nodes_marked_do_not_run.append(node) - elif node not in root_nodes and self._should_mark_node_dnr(node, parent_nodes): + if self._should_mark_node_dnr(node, parent_nodes): obj.do_not_run = True nodes_marked_do_not_run.append(node) diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index de436bf15f..81b304e264 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -1,6 +1,9 @@ import pytest import uuid +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_text + from awx.main.scheduler.dag_workflow import WorkflowDAG @@ -22,7 +25,7 @@ def wf_node_generator(mocker): pytest.count = 0 def fn(**kwargs): - wfn = WorkflowNode(id=pytest.count, **kwargs) + wfn = WorkflowNode(id=pytest.count, unified_job_template=object(), **kwargs) pytest.count += 1 return wfn return fn @@ -31,7 +34,7 @@ def wf_node_generator(mocker): @pytest.fixture def workflow_dag_1(wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator(unified_job_template=object()) for i in range(4)] + nodes = [wf_node_generator() for i in range(4)] map(lambda n: g.add_node(n), nodes) r''' @@ -85,8 +88,6 @@ class TestWorkflowDAG(): class TestDNR(): def test_mark_dnr_nodes(self, workflow_dag_1): (g, nodes) = workflow_dag_1 - for n in nodes: - n.unified_job_template = object() r''' S0 @@ -132,8 +133,6 @@ class TestIsWorkflowDone(): @pytest.fixture def workflow_dag_2(self, workflow_dag_1): (g, nodes) = workflow_dag_1 - for n in nodes: - n.unified_job_template = uuid.uuid4() r''' S0 /\ @@ -184,7 +183,7 @@ class TestIsWorkflowDone(): @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator(unified_job_template=object()) for i in range(1)] + nodes = [wf_node_generator() for i in range(1)] map(lambda n: g.add_node(n), nodes) r''' F0 @@ -207,7 +206,9 @@ class TestIsWorkflowDone(): (g, nodes) = workflow_dag_failed assert g.is_workflow_done() is True - assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[2].id)) + assert g.has_workflow_failed() == \ + (True, smart_text(_("No error handle path for workflow job node(s) [({},{})] workflow job node(s)" + " missing unified job template and error handle path [].").format(nodes[2].id, nodes[2].job.status))) def test_is_workflow_done_no_unified_job_tempalte_end(self, workflow_dag_failed): (g, nodes) = workflow_dag_failed @@ -216,8 +217,8 @@ class TestIsWorkflowDone(): assert g.is_workflow_done() is True assert g.has_workflow_failed() == \ - (True, "Workflow job node {} related unified job template missing" - " and is without an error handle path".format(nodes[2].id)) + (True, smart_text(_("No error handle path for workflow job node(s) [] workflow job node(s) missing" + " unified job template and error handle path [{}].").format(nodes[2].id))) def test_is_workflow_done_no_unified_job_tempalte_begin(self, workflow_dag_1): (g, nodes) = workflow_dag_1 @@ -227,25 +228,29 @@ class TestIsWorkflowDone(): assert g.is_workflow_done() is True assert g.has_workflow_failed() == \ - (True, "Workflow job node {} related unified job template missing" - " and is without an error handle path".format(nodes[0].id)) + (True, smart_text(_("No error handle path for workflow job node(s) [] workflow job node(s) missing" + " unified job template and error handle path [{}].").format(nodes[0].id))) def test_canceled_should_fail(self, workflow_dag_canceled): (g, nodes) = workflow_dag_canceled - assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'canceled' without an error handler path".format(nodes[0].id)) + assert g.has_workflow_failed() == \ + (True, smart_text(_("No error handle path for workflow job node(s) [({},{})] workflow job node(s)" + " missing unified job template and error handle path [].").format(nodes[0].id, nodes[0].job.status))) def test_failure_should_fail(self, workflow_dag_failure): (g, nodes) = workflow_dag_failure - assert g.has_workflow_failed() == (True, "Workflow job node {} has a status of 'failed' without an error handler path".format(nodes[0].id)) + assert g.has_workflow_failed() == \ + (True, smart_text(_("No error handle path for workflow job node(s) [({},{})] workflow job node(s)" + " missing unified job template and error handle path [].").format(nodes[0].id, nodes[0].job.status))) class TestBFSNodesToRun(): @pytest.fixture def workflow_dag_canceled(self, wf_node_generator): g = WorkflowDAG() - nodes = [wf_node_generator(unified_job_template=object()) for i in range(4)] + nodes = [wf_node_generator() for i in range(4)] map(lambda n: g.add_node(n), nodes) r''' C0 @@ -262,5 +267,6 @@ class TestBFSNodesToRun(): def test_cancel_still_runs_children(self, workflow_dag_canceled): (g, nodes) = workflow_dag_canceled + g.mark_dnr_nodes() assert set([nodes[1], nodes[2]]) == set(g.bfs_nodes_to_run()) From 0c8dde9718d048cc9c069fceeffcd5d1891e9d9e Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 19 Nov 2018 16:48:03 -0500 Subject: [PATCH 72/99] fix dfs_run_nodes() * Tried to re-use the topological sort order to crawl the graph to find the next node(s) to run. This is incorrect, we need to take into account the fail/success of jobs and directionally crawl the graph. --- awx/main/scheduler/dag_workflow.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 9c3cb3cc4e..8e1cb14113 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -70,20 +70,32 @@ class WorkflowDAG(SimpleDAG): return True def bfs_nodes_to_run(self): + nodes = self.get_root_nodes() nodes_found = [] + node_ids_visited = set() + + for index, n in enumerate(nodes): + obj = n['node_object'] + if obj.id in node_ids_visited: + continue + node_ids_visited.add(obj.id) - for node in self.sort_nodes_topological(): - obj = node['node_object'] if obj.do_not_run is True: continue - elif obj.job: - continue + + if obj.job: + if obj.job.status in ['failed', 'error', 'canceled']: + nodes.extend(self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) + elif obj.job.status == 'successful': + nodes.extend(self.get_dependencies(obj, 'success_nodes') + + self.get_dependencies(obj, 'always_nodes')) elif obj.unified_job_template is None: - continue - - if self._are_relevant_parents_finished(node): - nodes_found.append(node) - + nodes.extend(self.get_dependencies(obj, 'failure_nodes') + + self.get_dependencies(obj, 'always_nodes')) + else: + if self._are_relevant_parents_finished(n): + nodes_found.append(n) return [n['node_object'] for n in nodes_found] def cancel_node_jobs(self): From 4c1472776220b4edd873aa961828d0c8d4f8843f Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 20 Nov 2018 10:14:41 -0500 Subject: [PATCH 73/99] bump migration number --- ...orkflow_convergence.py => 0054_v340_workflow_convergence.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0053_v340_workflow_convergence.py => 0054_v340_workflow_convergence.py} (85%) diff --git a/awx/main/migrations/0053_v340_workflow_convergence.py b/awx/main/migrations/0054_v340_workflow_convergence.py similarity index 85% rename from awx/main/migrations/0053_v340_workflow_convergence.py rename to awx/main/migrations/0054_v340_workflow_convergence.py index 634b7c16ca..72811f01a2 100644 --- a/awx/main/migrations/0053_v340_workflow_convergence.py +++ b/awx/main/migrations/0054_v340_workflow_convergence.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0052_v340_remove_project_scm_delete_on_next_update'), + ('main', '0053_v340_workflow_inventory'), ] operations = [ From 1cfcaa72ad3cf18e911d2c4ed2f1593fef13126d Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Nov 2018 10:34:59 -0500 Subject: [PATCH 74/99] Fixed editNodeHelpMessage logic that was broken during merge conflict --- .../forms/workflow-node-form.controller.js | 34 +++++++++++++++++++ .../forms/workflow-node-form.directive.js | 1 + .../forms/workflow-node-form.partial.html | 2 ++ .../workflow-maker.partial.html | 2 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 7a5011efb2..3750e42969 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -17,6 +17,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; $scope.strings = TemplatesStrings; + $scope.editNodeHelpMessage = null; let templateList = _.cloneDeep(TemplateList); delete templateList.actions; @@ -139,6 +140,8 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService const finishConfiguringEdit = () => { + $scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.nodeConfig.node.fullUnifiedJobTemplateObject); + if (!$scope.readOnly) { let jobTemplate = new JobTemplate(); @@ -391,6 +394,36 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }; + const getEditNodeHelpMessage = (selectedTemplate) => { + if (selectedTemplate.type === "workflow_job_template") { + if ($scope.workflowJobTemplateObj.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + } + if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + } + } + if (selectedTemplate.type === "job_template") { + if ($scope.workflowJobTemplateObj.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); + } + if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); + } + } + return null; + }; + const templateManuallySelected = (selectedTemplate) => { if (promptWatcher) { @@ -406,6 +439,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } $scope.promptData = null; + $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate); if (selectedTemplate.type === "job_template") { let jobTemplate = new JobTemplate(); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js index 119e88908d..ff16c0b2cc 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js @@ -11,6 +11,7 @@ export default ['templateUrl', return { scope: { nodeConfig: '<', + workflowJobTemplateObj: '<', cancel: '&', select: '&', readOnly: '<' diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index e48a4e4f08..122948a8da 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -233,6 +233,8 @@
+
+
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index e44befc510..037af01a4d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -98,7 +98,7 @@
- + From 28a4bbbe8adb944defdbab663b13f23dbb479b28 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Nov 2018 10:52:00 -0500 Subject: [PATCH 75/99] Fixed jshint errors that fell out of merge conflict --- .../forms/workflow-node-form.controller.js | 60 +++++++++---------- .../workflow-maker.controller.js | 4 +- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 3750e42969..8ad413a38e 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -138,6 +138,36 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.nodeFormDataLoaded = true; }; + const getEditNodeHelpMessage = (selectedTemplate) => { + if (selectedTemplate.type === "workflow_job_template") { + if ($scope.workflowJobTemplateObj.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + } + if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + } + } + if (selectedTemplate.type === "job_template") { + if ($scope.workflowJobTemplateObj.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); + } + if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); + } + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); + } + } + return null; + }; + const finishConfiguringEdit = () => { $scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.nodeConfig.node.fullUnifiedJobTemplateObject); @@ -394,36 +424,6 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }; - const getEditNodeHelpMessage = (selectedTemplate) => { - if (selectedTemplate.type === "workflow_job_template") { - if ($scope.workflowJobTemplateObj.inventory) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); - } - } - if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); - } - } - } - if (selectedTemplate.type === "job_template") { - if ($scope.workflowJobTemplateObj.inventory) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); - } - return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); - } - if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { - if (selectedTemplate.ask_inventory_on_launch) { - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); - } - return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); - } - } - return null; - }; - const templateManuallySelected = (selectedTemplate) => { if (promptWatcher) { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 2dd983598a..d24dc9a457 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -7,11 +7,11 @@ export default ['$scope', 'TemplatesService', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', 'WorkflowChartService', - 'Wait', + 'Wait', '$state', function ($scope, TemplatesService, ProcessErrors, CreateSelect2, $q, JobTemplate, Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService, - Wait + Wait, $state ) { let deletedNodeIds = []; From 2eeca3cfd70ac6d6d9cf02660d7be1f14b2390c2 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 20 Nov 2018 11:18:56 -0500 Subject: [PATCH 76/99] add example workflow run to docs --- awx/main/scheduler/dag_simple.py | 6 ++- .../tests/unit/scheduler/test_dag_workflow.py | 46 ++++++++++++++++++ docs/img/workflow_step0.png | Bin 0 -> 64115 bytes docs/img/workflow_step1.png | Bin 0 -> 65092 bytes docs/img/workflow_step2.png | Bin 0 -> 66463 bytes docs/img/workflow_step3.png | Bin 0 -> 66571 bytes docs/img/workflow_step4.png | Bin 0 -> 67440 bytes docs/workflow.md | 29 ++++++++++- 8 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 docs/img/workflow_step0.png create mode 100644 docs/img/workflow_step1.png create mode 100644 docs/img/workflow_step2.png create mode 100644 docs/img/workflow_step3.png create mode 100644 docs/img/workflow_step4.png diff --git a/awx/main/scheduler/dag_simple.py b/awx/main/scheduler/dag_simple.py index 2ebda0375b..f3120581d1 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -59,7 +59,7 @@ class SimpleDAG(object): def __iter__(self): return self.nodes.__iter__() - def generate_graphviz_plot(self): + def generate_graphviz_plot(self, file_name="/awx_devel/graph.gv"): def run_status(obj): dnr = "RUN" status = "NA" @@ -83,6 +83,8 @@ class SimpleDAG(object): color = 'green' elif status == 'failed': color = 'red' + elif obj.do_not_run is True: + color = 'gray' doc += "%s [color = %s]\n" % ( run_status(n['node_object']), color @@ -96,7 +98,7 @@ class SimpleDAG(object): label, ) doc += "}\n" - gv_file = open('/awx_devel/graph.gv', 'w') + gv_file = open(file_name, 'w') gv_file.write(doc) gv_file.close() diff --git a/awx/main/tests/unit/scheduler/test_dag_workflow.py b/awx/main/tests/unit/scheduler/test_dag_workflow.py index 81b304e264..648a089a79 100644 --- a/awx/main/tests/unit/scheduler/test_dag_workflow.py +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -1,5 +1,6 @@ import pytest import uuid +import os from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_text @@ -270,3 +271,48 @@ class TestBFSNodesToRun(): g.mark_dnr_nodes() assert set([nodes[1], nodes[2]]) == set(g.bfs_nodes_to_run()) + + +@pytest.mark.skip(reason="Run manually to re-generate doc images") +class TestDocsExample(): + @pytest.fixture + def complex_dag(self, wf_node_generator): + g = WorkflowDAG() + nodes = [wf_node_generator() for i in range(10)] + map(lambda n: g.add_node(n), nodes) + + g.add_edge(nodes[0], nodes[1], "failure_nodes") + g.add_edge(nodes[0], nodes[2], "success_nodes") + g.add_edge(nodes[0], nodes[3], "always_nodes") + g.add_edge(nodes[1], nodes[4], "success_nodes") + g.add_edge(nodes[1], nodes[5], "failure_nodes") + + g.add_edge(nodes[2], nodes[6], "failure_nodes") + g.add_edge(nodes[3], nodes[6], "success_nodes") + g.add_edge(nodes[4], nodes[6], "always_nodes") + + g.add_edge(nodes[6], nodes[7], "always_nodes") + g.add_edge(nodes[6], nodes[8], "success_nodes") + g.add_edge(nodes[6], nodes[9], "failure_nodes") + + return (g, nodes) + + def test_dnr_step(self, complex_dag): + (g, nodes) = complex_dag + base_dir = '/awx_devel' + + g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step0.gv")) + nodes[0].job = Job(status='successful') + g.mark_dnr_nodes() + g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step1.gv")) + nodes[2].job = Job(status='successful') + nodes[3].job = Job(status='successful') + g.mark_dnr_nodes() + g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step2.gv")) + nodes[6].job = Job(status='failed') + g.mark_dnr_nodes() + g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step3.gv")) + nodes[7].job = Job(status='successful') + nodes[9].job = Job(status='successful') + g.mark_dnr_nodes() + g.generate_graphviz_plot(file_name=os.path.join(base_dir, "workflow_step4.gv")) diff --git a/docs/img/workflow_step0.png b/docs/img/workflow_step0.png new file mode 100644 index 0000000000000000000000000000000000000000..25495c139a9095edc70bdfa2fdfc11105fb8e7e2 GIT binary patch literal 64115 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

wsKrX52h6s&n-bHr(&DBf* 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 literal 0 HcmV?d00001 diff --git a/docs/img/workflow_step1.png b/docs/img/workflow_step1.png new file mode 100644 index 0000000000000000000000000000000000000000..984e31b528bfaa2a58382d27aa622af0b43d9ca0 GIT binary patch literal 65092 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 literal 0 HcmV?d00001 diff --git a/docs/img/workflow_step4.png b/docs/img/workflow_step4.png new file mode 100644 index 0000000000000000000000000000000000000000..1cf722d2f3518ac4eef0806014fdb32107be9de9 GIT binary patch literal 67440 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 JzQ0Y9#V{`#=Lr-(#|7oGD?x9LHpv5q{4 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 B$iV;r literal 0 HcmV?d00001 diff --git a/docs/workflow.md b/docs/workflow.md index ceb7f8ee65..e4becca74f 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -64,7 +64,7 @@ As stated, workflow job templates can be created with populated `extra_vars`. Th Job resources spawned by workflow jobs are needed by workflow to run correctly. Therefore deletion of spawned job resources is blocked while the underlying workflow job is executing. -Other than success and failure, a workflow spawned job resource can also end with status 'error' and 'canceled'. When a workflow spawned job resource errors, it is treated the same as failure. Canceling a workflow spawned job resource is also treated as a failure. If the unified job template of the node is null (which could be a result of deleting the unified job template or copying a workflow when the user lacks necessary permissions to use the resource), then the branch should stop executing in this case as well. +Other than success and failure, a workflow spawned job resource can also end with status 'error' and 'canceled'. When a workflow spawned job resource errors or is canceled, it is treated the same as failure. If the unified job template of the node is null (which could be a result of deleting the unified job template or copying a workflow when the user lacks necessary permissions to use the resource), then the node will be treated as 'failed' and the failure paths will continue to execute. A workflow job itself can also be canceled. In this case all its spawned job resources will be canceled if cancelable and following paths stop executing. @@ -92,6 +92,33 @@ Workflow jobs cannot be copied directly, instead a workflow job is implicitly co ### Artifacts Artifact support starts in Ansible and is carried through in Tower. The `set_stats` module is invoked by users, in a playbook, to register facts. Facts are passed in via `data:` argument. Note that the default `set_stats` parameters are the correct ones to work with Tower (i.e. `per_host: no`). Now that facts are registered, we will describe how facts are used. In Ansible, registered facts are "returned" to the callback plugin(s) via the `playbook_on_stats` event. Ansible users can configure whether or not they want the facts displayed through the global `show_custom_stats` configuration. Note that the `show_custom_stats` does not effect the artifacting feature of Tower. This only controls the displaying of `set_stats` fact data in Ansible output (also the output in Ansible playbooks ran in Tower). Tower uses a custom callback plugin that gathers the fact data set via `set_stats` in the `playbook_on_stats` handler and "ships" it back to Tower, saves it in the database, and makes it available on the job endpoint via the variable `artifacts`. The semantics and usage of `artifacts` throughout a workflow is described elsewhere in this document. +### Workflow Run Example +To best understand the nuances of workflow run logic we will look at an example workflow run as it progresses through the 'running' state. In the workflow examples below nodes are labeled `` where `do_not_run` can be `RUN` or `DNR` where `DNR` means 'do not run the node' and `RUN` which means will run the node. Nodes start out with `do_not_run = False` depicted as `RUN` in the pictures below. When nodes are known to not run they will be marked `DNR` and the state will not change. `job_status` is the job's status associated with the node. `node_id` is the unique id for the workflow job node. + +

+ + Workflow before running has started. +

+

+ + Root nodes are selected to run. A root node is a node with no incoming nodes. Node 0 is selected to run and results in a status of 'successful'. Nodes 1, 4, and 5 are marked 'DNR' because they are in the failure path. Node 6 is not marked 'DNR' because nodes 2 and 3 may run and result and node 6 running. The same reasoning is why nodes 7, 8, 9 are not marked 'DNR'. +

+

+ + Nodes 2 and 3 are selected to run and their job results are both 'successful'. Node 6 is not marked 'DNR' because node 3 will trigger node 6. +

+

+ + Node 6 is selected to run and the job results in 'failed'. Node 8 is marked 'DNR' because of the success path. Nodes 7 and 8 will be ran in the next cycle. +

+

+ + Node 7 and 8 are selected to run and their job results are both 'successful'. +

+ +The resulting state of the workflow job run above would be 'successful'. Although individual nodes fail, the overall workflow job status is 'successful' because all individual node failures have error handling paths ('failed_nodes' or 'always_nodes'). + + ## Test Coverage ### CRUD-related * Verify that CRUD operations on all workflow resources are working properly. Note workflow job nodes cannot be created or deleted independently, but verifications are needed to make sure when a workflow job is deleted, all its related workflow job nodes are deleted. From d8bf82a8cbea0ddb14fe486170aafc0ff4561d9b Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 20 Nov 2018 11:44:07 -0500 Subject: [PATCH 77/99] add help_text to do_not_run workflow field --- awx/main/migrations/0054_v340_workflow_convergence.py | 2 +- awx/main/models/workflow.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/main/migrations/0054_v340_workflow_convergence.py b/awx/main/migrations/0054_v340_workflow_convergence.py index 72811f01a2..e0c2f833fb 100644 --- a/awx/main/migrations/0054_v340_workflow_convergence.py +++ b/awx/main/migrations/0054_v340_workflow_convergence.py @@ -15,6 +15,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='workflowjobnode', name='do_not_run', - field=models.BooleanField(default=False), + field=models.BooleanField(default=False, help_text='Indidcates that a job will not be created when True. Workflow runtime semantics will mark this True if the node is in a path that will decidedly not be ran. A value of False means the node may not run.'), ), ] diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 200fd3b645..058b31f80b 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -185,7 +185,10 @@ class WorkflowJobNode(WorkflowNodeBase): editable=False, ) do_not_run = models.BooleanField( - default=False + default=False, + help_text=_("Indidcates that a job will not be created when True. Workflow runtime " + "semantics will mark this True if the node is in a path that will " + "decidedly not be ran. A value of False means the node may not run."), ) def get_absolute_url(self, request=None): From f8f2e005ba8aae935d4db279efa88afdb99f9271 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 20 Nov 2018 11:50:13 -0500 Subject: [PATCH 78/99] better comment for deciding parent's status --- awx/main/scheduler/dag_workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/scheduler/dag_workflow.py b/awx/main/scheduler/dag_workflow.py index 8e1cb14113..8676630247 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -61,7 +61,7 @@ class WorkflowDAG(SimpleDAG): continue elif p.unified_job_template is None: continue - # Node might run a job + # do_not_run is False, node might still run a job and thus blocks children elif not p.job: return False # Node decidedly got a job; check if job is done From 228e4124783d8345707022f040534070aa993753 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 20 Nov 2018 11:53:12 -0500 Subject: [PATCH 79/99] simplify workflow job failure reason * Log the more detailed reason for a workflow job failing but expose a simplified reason to users via job_explanation --- awx/main/scheduler/task_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index ccc953cada..d6f3ae14fa 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -186,7 +186,8 @@ class TaskManager(): update_fields = ['status', 'start_args'] workflow_job.status = new_status if reason: - workflow_job.job_explanation = reason + logger.info(reason) + workflow_job.job_explanation = "No error handling paths found, marking workflow as failed" update_fields.append('job_explanation') workflow_job.start_args = '' # blank field to remove encrypted passwords workflow_job.save(update_fields=update_fields) From 625c6c30fc970897c7455271f10b5fdee8fb272b Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Nov 2018 14:47:11 -0500 Subject: [PATCH 80/99] Fixed edge dropdown id --- .../workflow-maker/forms/workflow-node-form.controller.js | 2 +- .../workflow-maker/forms/workflow-node-form.partial.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 8ad413a38e..3615c227d3 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -131,7 +131,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService break; } CreateSelect2({ - element: '#workflow_node_edge_3', + element: '#workflow_node_edge', multiple: false }); diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 122948a8da..9c1e1609ab 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -130,7 +130,7 @@
- From ed40ba6267a9f4bd685242ff8c138444cd0fe384 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 20 Nov 2018 17:22:51 -0500 Subject: [PATCH 83/99] Fix searching on related fields --- .../workflow-maker/forms/workflow-node-form.partial.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 78b41c4c9f..1f50c66f9d 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -8,7 +8,7 @@
- +
@@ -47,7 +47,7 @@
- +
@@ -79,7 +79,7 @@
- +
From 38dc0b8e90c1e95848e7c92c342fdb6fe23f77e4 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 21 Nov 2018 13:50:18 -0500 Subject: [PATCH 84/99] fix workflow total jobs header to total nodes --- .../client/src/workflow-results/workflow-results.controller.js | 2 +- .../client/src/workflow-results/workflow-results.partial.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 9f7ad79bb1..cb874d04fc 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -78,7 +78,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', SHOW_MORE: i18n._('Show More'), }, results: { - TOTAL_JOBS: i18n._('Total Jobs'), + TOTAL_NODES: i18n._('Total Nodes'), ELAPSED: i18n._('Elapsed'), }, legend: { diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index bac9954513..34f85c9926 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -291,7 +291,7 @@
- {{ strings.results.TOTAL_JOBS }} + {{ strings.results.TOTAL_NODES }}
{{ workflow_nodes.length || 0}} From 343639d4b7dbf0a16fb2aee6104a0d9d9e8f3ef4 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 21 Nov 2018 14:07:36 -0500 Subject: [PATCH 85/99] fix workflow maker total templates header to total nodes --- awx/ui/client/features/templates/templates.strings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index ab77d0d7b1..cd06bdb755 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -105,7 +105,7 @@ function TemplatesStrings (BaseString) { INVENTORY_SYNC: t.s('Inventory Sync'), WORKFLOW: t.s('Workflow'), WARNING: t.s('Warning'), - TOTAL_TEMPLATES: t.s('TOTAL TEMPLATES'), + TOTAL_NODES: t.s('TOTAL NODES'), ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'), EDIT_TEMPLATE: t.s('EDIT TEMPLATE'), JOBS: t.s('JOBS'), From 762c882cd78e8e68c8668180101c5b502c9a6472 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 21 Nov 2018 14:14:28 -0500 Subject: [PATCH 86/99] consume workflow maker total nodes label change --- .../workflows/workflow-maker/workflow-maker.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 037af01a4d..7b6f9975d6 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -73,7 +73,7 @@
- {{strings.get('workflow_maker.TOTAL_TEMPLATES')}} + {{strings.get('workflow_maker.TOTAL_NODES')}}
From 3762ba7b245f45500bcf4b7473bd25e2b73d107d Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 21 Nov 2018 14:28:50 -0500 Subject: [PATCH 87/99] add back in workflow_nodes in order to be able to use it for count of nodes --- .../client/src/workflow-results/workflow-results.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index cb874d04fc..0ec32ad69c 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -115,6 +115,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', function init() { // put initially resolved request data on scope $scope.workflow = workflowData; + $scope.workflow_nodes = workflowNodes; $scope.workflowOptions = workflowDataOptions.actions.GET; $scope.labels = jobLabels; $scope.showManualControls = false; From 7b4521f980a12b4da2b0079ec7896f1df4847e6d Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 23 Nov 2018 17:08:21 -0500 Subject: [PATCH 88/99] workflow node prompt fixup * use workflow model and endpoint when node is workflow * always include template type in prompt data * skip missing inventory checks when node is workflow * skip checks for required credential fields when node is workflow --- .../forms/workflow-node-form.controller.js | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 281003d232..c68a469617 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -7,11 +7,11 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', - 'i18n', 'ParseTypeChange', + 'i18n', 'ParseTypeChange', 'WorkflowJobTemplateModel', function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, TemplatesStrings, CreateSelect2, Empty, generateList, qs, GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors, - i18n, ParseTypeChange + i18n, ParseTypeChange, WorkflowJobTemplate ) { let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; @@ -79,6 +79,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService credentialRequiresPassword = true; } }); + $scope.credentialRequiresPassword = credentialRequiresPassword; }; @@ -138,28 +139,28 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.nodeFormDataLoaded = true; }; - const getEditNodeHelpMessage = (selectedTemplate) => { + const getEditNodeHelpMessage = (selectedTemplate, workflowJobTemplateObj) => { if (selectedTemplate) { if (selectedTemplate.type === "workflow_job_template") { - if ($scope.workflowJobTemplateObj.inventory) { + if (workflowJobTemplateObj.inventory) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); } } - if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (workflowJobTemplateObj.ask_inventory_on_launch) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); } } } if (selectedTemplate.type === "job_template") { - if ($scope.workflowJobTemplateObj.inventory) { + if (workflowJobTemplateObj.inventory) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); } return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); } - if ($scope.workflowJobTemplateObj.ask_inventory_on_launch) { + if (workflowJobTemplateObj.ask_inventory_on_launch) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); } @@ -172,11 +173,13 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService }; const finishConfiguringEdit = () => { + const ujt = _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject'); + const templateType = _.get(ujt, 'type'); - $scope.editNodeHelpMessage = getEditNodeHelpMessage($scope.nodeConfig.node.fullUnifiedJobTemplateObject); + $scope.editNodeHelpMessage = getEditNodeHelpMessage(ujt, $scope.workflowJobTemplateObj); if (!$scope.readOnly) { - let jobTemplate = new JobTemplate(); + let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); if (_.get($scope, 'nodeConfig.node.promptData') && !_.isEmpty($scope.nodeConfig.node.promptData)) { $scope.promptData = _.cloneDeep($scope.nodeConfig.node.promptData); @@ -199,7 +202,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } else { $scope.showPromptButton = true; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { + if (templateType !== "workflow_job_template" && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeConfig.node.originalNodeObject.summary_fields.inventory')) { $scope.promptModalMissingReqFields = true; } else { $scope.promptModalMissingReqFields = false; @@ -326,6 +329,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService launchOptions: launchOptions, prompts: prompts, surveyQuestions: surveyQuestionRes.data.spec, + templateType: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.type, template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id }; @@ -350,6 +354,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService launchConf: launchConf, launchOptions: launchOptions, prompts: prompts, + templateType: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.type, template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id }; @@ -442,27 +447,30 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService } $scope.promptData = null; - $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate); + $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); - if (selectedTemplate.type === "job_template") { - let jobTemplate = new JobTemplate(); + if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { + let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) .then((responses) => { let launchConf = responses[1].data; - if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { - $scope.selectedTemplateInvalid = true; - } else { - $scope.selectedTemplateInvalid = false; - } - - if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { - $scope.credentialRequiresPassword = true; - } else { - $scope.credentialRequiresPassword = false; + let credentialRequiresPassword = false; + let selectedTemplateInvalid = false; + + if (selectedTemplate.type !== "workflow_job_template") { + if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { + selectedTemplateInvalid = true; + } + + if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { + credentialRequiresPassword = true; + } } + $scope.credentialRequiresPassword = credentialRequiresPassword; + $scope.selectedTemplateInvalid = selectedTemplateInvalid; $scope.selectedTemplate = angular.copy(selectedTemplate); if (!launchConf.survey_enabled && @@ -481,11 +489,12 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService $scope.promptModalMissingReqFields = false; } else { $scope.showPromptButton = true; + $scope.promptModalMissingReqFields = false; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { - $scope.promptModalMissingReqFields = true; - } else { - $scope.promptModalMissingReqFields = false; + if (selectedTemplate.type !== "workflow_job_template") { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { + $scope.promptModalMissingReqFields = true; + } } if (launchConf.survey_enabled) { @@ -504,6 +513,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService launchOptions: responses[0].data, surveyQuestions: processed.surveyQuestions, template: selectedTemplate.id, + templateType: selectedTemplate.type, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, launchOptions: responses[0].data @@ -527,6 +537,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService launchConf: responses[1].data, launchOptions: responses[0].data, template: selectedTemplate.id, + templateType: selectedTemplate.type, prompts: PromptService.processPromptValues({ launchConf: responses[1].data, launchOptions: responses[0].data From 65ec1d18ad3d8c3bbe54da1830692b55536be443 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 23 Nov 2018 17:32:57 -0500 Subject: [PATCH 89/99] skip missing inventory prompt value check when selecting workflow node --- .../forms/workflow-node-form.controller.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index c68a469617..4f5a855b46 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -91,12 +91,19 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService ]; promptWatcher = $scope.$watchGroup(promptDataToWatch, () => { + const templateType = _.get($scope, 'promptData.templateType'); let missingPromptValue = false; + if ($scope.missingSurveyValue) { missingPromptValue = true; - } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { - missingPromptValue = true; } + + if (templateType !== "workflow_job_template") { + if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + missingPromptValue = true; + } + } + $scope.promptModalMissingReqFields = missingPromptValue; }); From d5f07a965220480487eea4b8276e23878622fa0f Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 23 Nov 2018 17:53:02 -0500 Subject: [PATCH 90/99] hide inventory help message when not on jobs tab --- .../workflow-maker/forms/workflow-node-form.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index 1f50c66f9d..ca232115b8 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -233,7 +233,7 @@
-
+

From bfa361c87f0c0dcf428a3bbae0cfc3316331e373 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 23 Nov 2018 17:59:31 -0500 Subject: [PATCH 91/99] hide prompt button when not on jobs tab --- .../workflow-maker/forms/workflow-node-form.controller.js | 7 ++++++- .../workflow-maker/forms/workflow-node-form.partial.html | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index 4f5a855b46..e622a31782 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -154,23 +154,28 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); } } - if (workflowJobTemplateObj.ask_inventory_on_launch) { + + if (workflowJobTemplateObj.ask_inventory_on_launch) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); } } } + if (selectedTemplate.type === "job_template") { if (workflowJobTemplateObj.inventory) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); } + return $scope.strings.get('workflow_maker.INVENTORY_WILL_NOT_OVERRIDE'); } + if (workflowJobTemplateObj.ask_inventory_on_launch) { if (selectedTemplate.ask_inventory_on_launch) { return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_OVERRIDE'); } + return $scope.strings.get('workflow_maker.INVENTORY_PROMPT_WILL_NOT_OVERRIDE'); } } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html index ca232115b8..56ed5f85a0 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -236,7 +236,7 @@

- + From 3975a2ecdb5e86eb2997efe6b55edfb6289e3cbe Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 26 Nov 2018 09:02:25 -0500 Subject: [PATCH 92/99] fix linkpath class --- .../workflows/workflow-chart/workflow-chart.directive.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 0b8825e228..375b2b5acd 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -322,9 +322,6 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge baseSvg.selectAll(".WorkflowChart-linkPath") .transition() - .attr("class", function(d) { - return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; - }) .attr("d", lineData) .attr('stroke', function(d) { let edgeType = d.edgeType; @@ -477,9 +474,7 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge // Add entering links in the parent’s old position. linkEnter.insert("path", "g") - .attr("class", function(d) { - return (d.source.id === scope.graphState.nodeBeingAdded || d.target.id === scope.graphState.nodeBeingAdded) ? "WorkflowChart-linkPath WorkflowChart-isNodeBeingAdded" : "WorkflowChart-linkPath"; - }) + .attr("class", "WorkflowChart-linkPath") .attr("d", lineData) .call(edit_link) .on("mouseenter", function(d) { From 62a1f10c42705cb01178a96d44954580b6ca3827 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 26 Nov 2018 09:15:37 -0500 Subject: [PATCH 93/99] Fix node pagination for project/inv --- .../workflow-maker/forms/workflow-node-form.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js index e622a31782..2894ab91dd 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -591,7 +591,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService ); $scope.wf_maker_project_queryset = { - page_size: '5', + page_size: '10', order_by: 'name' }; @@ -607,7 +607,7 @@ export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService ); $scope.wf_maker_inventory_source_dataset = { - page_size: '5', + page_size: '10', order_by: 'name', not__source: '' }; From 7bad01e193ab3f8544b6f6d203fd6dabf10d752c Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 26 Nov 2018 11:20:56 -0500 Subject: [PATCH 94/99] Fixes e2e workflow visualizer tests --- .../forms/workflow-link-form.controller.js | 2 +- .../forms/workflow-link-form.partial.html | 2 +- .../forms/workflow-node-form.partial.html | 6 +- .../e2e/tests/test-workflow-visualizer.js | 195 +++++++++++------- 4 files changed, 124 insertions(+), 81 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js index bbf8f095c3..3e27f823ca 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.controller.js @@ -29,7 +29,7 @@ export default ['$scope', 'TemplatesStrings', 'CreateSelect2', value: $scope.linkConfig.edgeType }; CreateSelect2({ - element: '#workflow_node_edge_2', + element: '#workflow_link_edge', multiple: false }); } diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html index cf384b6411..bcbdf10937 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -9,7 +9,7 @@
+ +
+