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) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 87140b02b1..ac65ce9247 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,35 +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] - 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: - 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) + 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 diff --git a/awx/main/migrations/0054_v340_workflow_convergence.py b/awx/main/migrations/0054_v340_workflow_convergence.py new file mode 100644 index 0000000000..e0c2f833fb --- /dev/null +++ b/awx/main/migrations/0054_v340_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', '0053_v340_workflow_inventory'), + ] + + operations = [ + migrations.AddField( + model_name='workflowjobnode', + name='do_not_run', + 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 2be55d2992..058b31f80b 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): @@ -184,6 +184,12 @@ class WorkflowJobNode(WorkflowNodeBase): default={}, editable=False, ) + do_not_run = 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."), + ) 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..f3120581d1 100644 --- a/awx/main/scheduler/dag_simple.py +++ b/awx/main/scheduler/dag_simple.py @@ -1,11 +1,4 @@ - -from awx.main.models import ( - Job, - AdHocCommand, - InventoryUpdate, - ProjectUpdate, - WorkflowJob, -) +from collections import deque class SimpleDAG(object): @@ -13,12 +6,51 @@ class SimpleDAG(object): def __init__(self): self.nodes = [] - self.edges = [] + self.root_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 + self.nodes + ''' + self.node_obj_to_node_index = dict() + + r''' + Track per-node from->to edges + + i.e. + { + 'success': { + 1: [2, 3], + 4: [2, 3], + }, + 'failed': { + 1: [5], + } + } + ''' + 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): @@ -27,98 +59,169 @@ class SimpleDAG(object): def __iter__(self): 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" - else: - type_str = "Unknown" - type_str += "%s" % str(obj.id) - return type_str + def generate_graphviz_plot(self, file_name="/awx_devel/graph.gv"): + def run_status(obj): + dnr = "RUN" + status = "NA" + if hasattr(obj, 'job') and obj.job and hasattr(obj.job, 'status'): + status = obj.job.status + if hasattr(obj, 'do_not_run') and obj.do_not_run is True: + 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 hasattr(obj, 'job') and obj.job: + status = obj.job.status + color = 'black' + if status == 'successful': + color = 'green' + elif status == 'failed': + color = 'red' + elif obj.do_not_run is True: + color = 'gray' doc += "%s [color = %s]\n" % ( - short_string_obj(n['node_object']), - "red" if n['node_object'].status == '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']), - label, + run_status(n['node_object']), + color ) + 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('/tmp/graph.gv', 'w') + gv_file = open(file_name, 'w') gv_file.write(doc) gv_file.close() 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): + 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) - if from_obj_ord is None or to_obj_ord is None: - raise LookupError("Object not found") - 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 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)) + + 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_by_label[label][from_obj_ord].append(to_obj_ord) + self.node_to_edges_by_label[label][to_obj_ord].append(from_obj_ord) 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_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_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 - - 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 + 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_root_nodes(self): - 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()] + node_objs_visited = set([]) + path = set([]) + stack = node_objs + res = False + + if len(self.nodes) != 0 and len(node_objs) == 0: + return True + + while stack: + node_obj = stack.pop() + + 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: + if node_obj in path: + 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 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 924e459929..8676630247 100644 --- a/awx/main/scheduler/dag_workflow.py +++ b/awx/main/scheduler/dag_workflow.py @@ -1,4 +1,13 @@ +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_text + +# Python +from awx.main.models import ( + WorkflowJobTemplateNode, + WorkflowJobNode, +) + # AWX from awx.main.scheduler.dag_simple import SimpleDAG @@ -9,44 +18,84 @@ class WorkflowDAG(SimpleDAG): if workflow_job: self._init_graph(workflow_job) - def _init_graph(self, workflow_job): - node_qs = workflow_job.workflow_job_nodes - workflow_nodes = node_qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes').all() - for workflow_node in workflow_nodes: + 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(**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(**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)) + + 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') + + 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 + elif p.unified_job_template is None: + continue + # 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 + elif p.job and p.job.status not in ['successful', 'failed', 'error', 'canceled']: + 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 = [] + node_ids_visited = set() for index, n in enumerate(nodes): obj = n['node_object'] - job = obj.job - - if not job: - 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']: + if obj.id in node_ids_visited: continue - elif 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': - 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) + node_ids_visited.add(obj.id) + + if obj.do_not_run is True: + 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) return [n['node_object'] for n in nodes_found] def cancel_node_jobs(self): @@ -63,40 +112,113 @@ 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 and obj.unified_job_template: + 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 + def has_workflow_failed(self): + failed_nodes = [] + res = False + failed_path_nodes_id_status = [] + failed_unified_job_template_node_ids = [] - if obj.unified_job_template is None: - is_failed = True - continue - elif not job: - return False, False + for node in self.nodes: + obj = node['node_object'] + if obj.do_not_run is False and obj.unified_job_template is None: + failed_nodes.append(node) + elif obj.job and obj.job.status in ['failed', 'canceled', 'error']: + failed_nodes.append(node) - 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 + 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: + res = True + failed_unified_job_template_node_ids.append(str(obj.id)) else: - is_failed = True if children_all else job.status in ['failed', 'canceled', 'error'] + res = True + failed_path_nodes_id_status.append((str(obj.id), obj.job.status)) - if job.status in ['canceled', 'error']: - continue - elif job.status == 'failed': - nodes.extend(children_failed + children_always) - elif job.status == 'successful': - nodes.extend(children_success + children_always) + 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''' + 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 and n.unified_job_template: + return False + return True + + + r''' + 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.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')): + return False + 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 + else: + return False + 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 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 + return False + return True + + def mark_dnr_nodes(self): + root_nodes = self.get_root_nodes() + nodes_marked_do_not_run = [] + + for node in self.sort_nodes_topological(): + obj = node['node_object'] + + 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 self._should_mark_node_dnr(node, parent_nodes): + obj.do_not_run = True + nodes_marked_do_not_run.append(node) + + 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..d6f3ae14fa 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,16 +173,24 @@ 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, 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: + 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=['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 d7d03a53bb..41c8be70e3 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 @@ -58,46 +64,100 @@ 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, 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(): jt.delete() relaunched = wfj.create_relaunch_workflow_job() dag = WorkflowDAG(workflow_job=relaunched) - is_done, has_failed = dag.is_workflow_done() - 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() + dag.mark_dnr_nodes() + is_done = dag.is_workflow_done() + 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]) 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, reason = dag.has_workflow_failed() self.assertFalse(is_done) self.assertFalse(has_failed) + assert reason is None + + +@pytest.mark.django_db +class TestWorkflowDNR(): + @pytest.fixture + def workflow_job_fn(self): + def fn(states=['new', 'new', 'new', 'new', 'new', 'new']): + r""" + 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 @@ -186,18 +246,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 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' - + 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) 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 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..648a089a79 --- /dev/null +++ b/awx/main/tests/unit/scheduler/test_dag_workflow.py @@ -0,0 +1,318 @@ +import pytest +import uuid +import os + +from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import smart_text + +from awx.main.scheduler.dag_workflow import WorkflowDAG + + +class Job(): + def __init__(self, status='successful'): + self.status = status + + +class WorkflowNode(object): + def __init__(self, id=None, job=None, do_not_run=False, unified_job_template=None): + 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 + + +@pytest.fixture +def wf_node_generator(mocker): + pytest.count = 0 + + def fn(**kwargs): + wfn = WorkflowNode(id=pytest.count, unified_job_template=object(), **kwargs) + pytest.count += 1 + return wfn + 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(): + 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 workflow_dag_2(self, workflow_dag_1): + (g, nodes) = workflow_dag_1 + r''' + S0 + /\ + S / \ + / \ + S1 | + | | + F | | S + | | + DNR 3 | + \ | + F \ | + \/ + W2 + ''' + nodes[0].job = Job(status='successful') + g.mark_dnr_nodes() + nodes[1].job = Job(status='successful') + 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') + g.mark_dnr_nodes() + nodes[1].job = Job(status='successful') + g.mark_dnr_nodes() + nodes[2].job = Job(status='failed') + return (g, nodes) + + @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_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, 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 + + nodes[2].unified_job_template = None + + assert g.is_workflow_done() is True + 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))) + + 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, 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, 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, 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() 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 + 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/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/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 0efcff23c4..cd06bdb755 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -105,12 +105,14 @@ 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'), 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,14 @@ 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.'), - } + 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'), + 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.') + }; } 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/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/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.block.less b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.block.less index 52242d6387..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 @@ -1,26 +1,105 @@ -.link circle, .link .linkCross, .node .addCircle, .node .removeCircle, .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: #E1E1E1; +} + +.WorkflowChart-linkHovering .WorkflowChart-linkPath { + cursor: pointer; +} + +.WorkflowChart-link circle, +.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-nodeLinkIcon { 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-linkCircle { + fill: @default-link; +} + +.WorkflowChart-linkCircle.WorkflowChart-linkButtonHovering { + fill: @default-link-hov; +} + +.WorkflowChart-node .WorkflowChart-nodeRemoveCircle { fill: @default-err; } -.removeCircle.removeHovering { +.WorkflowChart-nodeRemoveCircle.removeHovering { fill: @default-err-hov; } -.node { - font-size: 12px; - font-family: 'Open Sans', sans-serif, 'FontAwesome'; +.WorkflowChart-node .WorkflowChart-rect { + fill: @default-secondary-bg; +} + +.WorkflowChart-rect.WorkflowChart-isNodeBeingAdded { + stroke-dasharray: 3; +} + +.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, +.WorkflowChart-addHovering path, +.WorkflowChart-addHovering .WorkflowChart-betweenNodesIcon { + cursor: pointer; + opacity: 1; +} + +.WorkflowChart-link.WorkflowChart-isNodeBeingAdded { + stroke-dasharray: 3; +} + +.WorkflowChart-svg { + border-bottom-left-radius: 5px; + width: 100%; } .WorkflowChart-defaultText { @@ -32,72 +111,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, -.hovering .WorkflowChart-hoverPath, -.hovering .linkCross { - 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; @@ -105,6 +148,7 @@ .WorkflowChart-activeNode { fill: @default-link; } + .WorkflowChart-elapsedHolder { background-color: @b7grey; color: @default-bg; @@ -113,26 +157,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 { + fill: @default-interface-txt; +} + .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 fefeecf385..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 @@ -4,1187 +4,1458 @@ * 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: { + graphState: '=', + readOnly: '<', + addNodeWithoutChild: '&', + addNodeWithChild: '&', + editNode: '&', + deleteNode: '&', + editLink: '&', + selectNodeForLinking: '&', + 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 nodeW = 180, + nodeH = 60, + rootW = 60, + rootH = 40, + startNodeOffsetY = scope.mode === 'details' ? 17 : 10, + maxNodeTextLength = 27, + windowHeight, + windowWidth, + line, + zoomObj, + baseSvg, + svgGroup, + graphLoaded, + nodePositionMap = {}; - scope.dimensionsSet = false; + scope.dimensionsSet = false; - $timeout(function () { - let dimensions = calcAvailableScreenSpace(); + const calcAvailableScreenSpace = () => { + let dimensions = {}; - windowHeight = dimensions.height; - windowWidth = dimensions.width; + 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; - $('.WorkflowMaker-chart').css("height", windowHeight); + dimensions.height = proposedHeight > 200 ? proposedHeight : 200; + dimensions.width = panelWidth; + } - scope.dimensionsSet = true; + return dimensions; + }; - init(); + // Dagre is going to shift the root node around as nodes are added/removed + // This function ensures that the user doesn't experience that + const normalizeY = ((y) => { + return y - nodePositionMap[1].y; + }); + + 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); + 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) { + if (scope.mode === "details") { + sourceY = sourceY + 17; + } else { + 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 + const wrap = (text) => { + if(text && text.length > maxNodeTextLength) { + return text.substring(0,maxNodeTextLength) + '...'; + } + else { + return text; + } + }; + + 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; } + 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 + const naturalZoom = () => { + let scale = d3.event.scale, + translation = d3.event.translate; + + translation = [translation[0], 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 + const 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, translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); + zoomObj.scale(scale); + zoomObj.translate([translateX, translateY]); + }; + + const 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]); + }; + + const resetZoomAndPan = () => { + svgGroup.attr("transform", "translate(0," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + // Update the zoomObj + zoomObj.scale(1); + zoomObj.translate([0,0]); + }; + + const zoomToFitChart = () => { + let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), + availableScreenSpace = calcAvailableScreenSpace(), + currentZoomValue = zoomObj.scale(), + unscaledH = graphDimensions.height/currentZoomValue, + unscaledW = graphDimensions.width/currentZoomValue, + scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, + scaleNeededForMaxWidth = (availableScreenSpace.width)/unscaledW, + lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10); + + manualZoom(scaleToFit*100); + + scope.workflowZoomed({ + zoom: scaleToFit }); - 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; - }); + 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)]); + }; - 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 + const updateGraph = () => { + 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 + } + }; } - ]; - - 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 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}
+
`; }); - - 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 + ")"; + 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}`; }); + }; - 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); + let 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 { - 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 { - return "#D7D7D7"; - } - }) - .attr('stroke-width', "2px") - .attr("class", function (d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); + g.setNode(node.id, { label: "", width: rootW, height: rootH }); + } + } else { + g.setNode(node.id, { label: "", width: nodeW, height: nodeH }); + } + }); - 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"; - }); + scope.graphState.arrayOfLinksForChart.forEach((link) => { + g.setEdge(link.source.id, link.target.id); + }); - 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); + dagre.layout(g); - 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"; - }); + nodePositionMap = {}; - 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; - }); + g.nodes().forEach((node) => { + nodePositionMap[node] = g.node(node); + }); - 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"; - }); + let links = svgGroup.selectAll(".WorkflowChart-link") + .data(scope.graphState.arrayOfLinksForChart, function(d) { return `${d.source.id}-${d.target.id}`; }); - 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"; - }); + // Remove any stale links + links.exit().remove(); - 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(); + // Update existing links + baseSvg.selectAll(".WorkflowChart-link") + .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); - 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"; - }); + baseSvg.selectAll(".WorkflowChart-linkPath") + .transition() + .attr("d", lineData) + .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"; } }); - node.exit().remove(); + 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 ( + 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(' '); + }) + .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; - if (nodes && nodes.length > 1 && !graphLoaded) { - zoomToFitChart(); + 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 (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; + }) + .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 (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; + + 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") + .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 ( + 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(' '); + }) + .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( + d.edgeType !== 'placeholder' && + !scope.graphState.isLinkMode && + d.source.id !== 1 && + 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); + + buildLinkTooltip(d); + } + }) + .on("mouseout", function(d){ + 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); + } + $('.WorkflowChart-tooltip').remove(); + }); + + // Add entering links in the parent’s old position. + linkEnter.insert("path", "g") + .attr("class", "WorkflowChart-linkPath") + .attr("d", lineData) + .call(edit_link) + .on("mouseenter", function(d) { + if( + d.edgeType !== 'placeholder' && + !scope.graphState.isLinkMode && + d.source.id !== 1 && + 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); + + buildLinkTooltip(d); + } + }) + .on("mouseleave", function(d){ + 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); + } + $('.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 (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; + }) + .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`); + 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); + }); + + linkEnter.append("path") + .attr("class", "WorkflowChart-betweenNodesIcon") + .style("fill", "white") + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .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; + + 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`); + 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); + }); + + let nodes = svgGroup.selectAll('.WorkflowChart-node') + .data(scope.graphState.arrayOfNodesForChart, function(d) { return d.id; }); + + // Remove any stale nodes + 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; }); + + baseSvg.selectAll(".WorkflowChart-nodeAddIcon") + .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.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeLinkIcon") + .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.graphState.isLinkMode || d.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-nodeRemoveIcon") + .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) { + 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.id === scope.graphState.nodeBeingAdded ? "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" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); + + baseSvg.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"; + }); + + 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 + let 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.id === scope.graphState.nodeBeingAdded ? "none" : null; }); + + baseSvg.selectAll(".WorkflowChart-activeNode") + .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.graphState.addLinkSource === d.id ? "#337AB7" : "#D7D7D7"; }) + .style("display", function(d) { return scope.graphState.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;}) + .attr("transform", function (d) { + return "translate(" + nodePositionMap[d.id].x + "," + normalizeY(nodePositionMap[d.id].y) + ")"; + }); + + nodeEnter.each(function(d) { + let thisNode = d3.select(this); + 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; + 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"); } - - 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 if(d.id === 1 && 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_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_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.graphState.isLinkMode ? null : "none"; }); + 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 { - 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.id === scope.graphState.nodeBeingAdded ? "WorkflowChart-rect WorkflowChart-isNodeBeingAdded" : "WorkflowChart-rect"; + classString += !_.get(d, 'unifiedJobTemplate.name') ? " 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.id === scope.graphState.nodeBeingEdited ? 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) { + const name = _.get(d, 'unifiedJobTemplate.name'); + return name ? wrap(name) : ""; + }); + + 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.id === scope.graphState.nodeBeingAdded ? "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", function(d) { return d.isInvalidLinkTarget ? "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--disabled" : "WorkflowChart-nodeOverlay WorkflowChart-nodeOverlay--transparent"; }) + .call(node_click) + .on("mouseover", function(d) { + 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) { + // 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.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.WorkflowChart-node").sort(function (a) { + 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 + 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) + "
"; + }); + } + + 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]; + + 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]; + + const startX = sourceNodeX + nodeW/2; + const startY = sourceNodeY + nodeH/2; + + const finishX = targetNodeX + nodeW/2; + const finishY = targetNodeY + nodeH/2; + + const polylinePoints = { + start: { + x: startX, + y: startY + }, + third: { + x: startX + (finishX - startX)/3, + y: startY + (finishY - startY)/3 + }, + midpoint: { + x: startX + (finishX - startX)/2, + y: startY + (finishY - startY)/2 + }, + twoThird: { + x: startX + 2*(finishX - startX)/3, + y: startY + 2*(finishY - startY)/3 + }, + finish: { + x: finishX, + y: finishY + } + }; + + $('.WorkflowChart-potentialLink').remove(); + + svgGroup.insert("polyline", '.WorkflowChart-node') + .attr("class", "WorkflowChart-potentialLink") + .attr("points", `${polylinePoints.start.x},${polylinePoints.start.y} ${polylinePoints.third.x},${polylinePoints.third.y} ${polylinePoints.midpoint.x},${polylinePoints.midpoint.y} ${polylinePoints.twoThird.x},${polylinePoints.twoThird.y} ${polylinePoints.finish.x},${polylinePoints.finish.y}`) + .attr("stroke-dasharray","5,5") + .attr("stroke-width", "2") + .attr("stroke", "#D7D7D7") + .attr('marker-mid', "url(#aw-workflow-chart-arrow)"); + } + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", 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(); + $('.WorkflowChart-potentialLink').remove(); + if(d.id !== 1) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); } - } - 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("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", "WorkflowChart-addCircle WorkflowChart-nodeAddCircle") + .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) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-add") + .classed("WorkflowChart-addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + d3.select("#node-" + d.id + "-add") + .classed("WorkflowChart-addHovering", false); + }); + thisNode.append("path") + .attr("class", "WorkflowChart-nodeAddIcon") + .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.id === scope.graphState.nodeBeingAdded || scope.readOnly ? "none" : null; }) + .call(add_node_without_child) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-add") + .classed("WorkflowChart-addHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + 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.id === scope.graphState.nodeBeingAdded || 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("foreignObject") + .attr("x", nodeW - 6) + .attr("y", nodeH/2 - 9) + .attr("height", "17px") + .attr("width", "13px") + .style("font-size","14px") + .html(function () { + return ``; + }) + .attr("class", "WorkflowChart-nodeLinkIcon") + .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) + .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.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + thisNode.append("path") + .attr("class", "WorkflowChart-nodeRemoveIcon") + .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.id === 1 || d.id === scope.graphState.nodeBeingAdded || scope.readOnly) ? "none" : null; }) + .call(remove_node) + .on("mouseover", function(d) { + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function(d){ + d3.select("#node-" + d.id) + .classed("WorkflowChart-nodeHovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); - t.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function (d) { + thisNode.append("circle") + .attr("class", function(d) { - let statusClass = "WorkflowChart-nodeStatus "; + 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; + 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); - })(); - } - }); + return statusClass; + }) + .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) + .attr("cy", 10) + .attr("cx", 10) + .attr("r", 6); - 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) : ""; - }); + 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"; }); + } + }); - t.selectAll(".WorkflowChart-detailsLink") - .style("display", function (d) { - return d.job && d.job.status && d.job.id ? null : "none"; - }); + if(scope.graphState.arrayOfNodesForChart && scope.graphState.arrayOfNodesForChart.length > 1 && !graphLoaded) { + zoomToFitChart(); + } - t.selectAll(".WorkflowChart-deletedText") - .style("display", function (d) { - return d.unifiedJobTemplate || d.placeholder ? "none" : null; - }); + graphLoaded = true; - t.selectAll(".WorkflowChart-conflictText") - .style("display", function (d) { - return (d.edgeConflict && !d.placeholder) ? null : "none"; - }); + // This will make sure that all the link elements appear before the nodes in the dom + svgGroup.selectAll(".WorkflowChart-node").order(); + } + else if(!scope.watchDimensionsSet){ + scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ + if(scope.dimensionsSet) { + scope.watchDimensionsSet(); + scope.watchDimensionsSet = null; + updateGraph(); + } + }); + } + }; - 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_without_child() { + this.on("click", function(d) { + if(!scope.readOnly && !scope.graphState.isLinkMode) { + scope.addNodeWithoutChild({ + parent: d }); } - } + }); + } - 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_with_child() { + this.on("click", function(d) { + if(!scope.readOnly && !scope.graphState.isLinkMode && d.edgeType !== 'placeholder') { + scope.addNodeWithChild({ + link: d + }); + } + }); + } + + function remove_node() { + this.on("click", function(d) { + if(d.id !== 1 && !scope.readOnly && !scope.graphState.isLinkMode) { + scope.deleteNode({ + nodeToDelete: d + }); + } + }); + } + + function node_click() { + this.on("click", function(d) { + if(d.id !== scope.graphState.nodeBeingAdded){ + if(scope.graphState.isLinkMode && !d.isInvalidLinkTarget && scope.graphState.addLinkSource !== d.id) { + $('.WorkflowChart-potentialLink').remove(); + scope.selectNodeForLinking({ + nodeToStartLink: d }); - } - }); - } - - 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) { + } else if(!scope.graphState.isLinkMode) { scope.editNode({ nodeToEdit: d }); } - }); + + } + }); + } + + function edit_link() { + this.on("click", function(d) { + 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 + }); + } + }); + } + + function add_link() { + this.on("click", function(d) { + if (!scope.readOnly && !scope.graphState.isLinkMode) { + scope.selectNodeForLinking({ + nodeToStartLink: 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) { + 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, + }); + } + }); + } + + scope.$on('refreshWorkflowChart', function(){ + if(scope.graphState) { + updateGraph(); } + }); - 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) { + scope.$on('panWorkflowChart', function(evt, params) { + manualPan(params.direction); + }); - 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, - }); - } - }; + scope.$on('resetWorkflowChart', function(){ + resetZoomAndPan(); + }); - 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.$on('zoomWorkflowChart', function(evt, params) { + manualZoom(params.zoom); + }); + + scope.$on('zoomToFitChart', function() { + zoomToFitChart(); + }); + + let clearWatchGraphState = scope.$watch('graphState.arrayOfNodesForChart', function(newVal) { + if(newVal) { + updateGraph(); + clearWatchGraphState(); } + }); - scope.$watch('canAddWorkflowJobTemplate', function () { - // Redraw the graph if permissions change - if (scope.treeData) { - update(); - } + 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); + } + + $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) + ")"); + + const defs = baseSvg.append("defs"); + + defs.append("marker") + .attr("id", "aw-workflow-chart-arrow") + .attr("viewBox", "0 -5 10 10") + .attr("refX", 5) + .attr("markerWidth", 6) + .attr("markerHeight", 6) + .attr("orient", "auto") + .append("path") + .attr("d", "M0,-5L10,0L0,5") + .attr('fill', "#D7D7D7"); + }); + + 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(); + }); }); - - 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() { + } + else { + scope.$on('workflowMakerModalResized', function(){ 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("height", dimensions.height); - }); - } + }); } - }; - } -]; + } + }; +}]; 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..beec159e37 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.service.js @@ -0,0 +1,101 @@ +export default [function(){ + return { + generateArraysOfNodesAndLinks: (allNodes) => { + let nonRootNodeIds = []; + let allNodeIds = []; + let arrayOfLinksForChart = []; + let nodeIdToChartNodeIdMapping = {}; + let chartNodeIdToIndexMapping = {}; + let nodeRef = {}; + let nodeIdCounter = 1; + let arrayOfNodesForChart = [ + { + id: nodeIdCounter, + unifiedJobTemplate: { + name: "START" + } + } + ]; + 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) { + nodeRef[nodeIdCounter].unifiedJobTemplate = 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, + nodeIdToChartNodeIdMapping, + nodeRef, + workflowMakerNodeIdCounter: 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..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: 50, - 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 @@ -54,7 +38,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 @@ -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/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..3e27f823ca --- /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', + function($scope, TemplatesStrings, CreateSelect2) { + $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_link_edge', + 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..ee0a447a92 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.directive.js @@ -0,0 +1,24 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowLinkFormController from './workflow-link-form.controller'; + +export default ['templateUrl', + function(templateUrl) { + return { + scope: { + linkConfig: '<', + readOnly: '<', + cancel: '&', + select: '&', + unlink: '&' + }, + 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..bcbdf10937 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-link-form.partial.html @@ -0,0 +1,30 @@ +
{{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')}}
+ + +
+ +
+
+
+
+ + + + +
+
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..067842abe2 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.controller.js @@ -0,0 +1,712 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['$scope', 'TemplatesService', 'JobTemplateModel', 'PromptService', 'Rest', '$q', + 'TemplatesStrings', 'CreateSelect2', 'Empty', 'generateList', 'QuerySet', + 'GetBasePath', 'TemplateList', 'ProjectList', 'InventorySourcesList', 'ProcessErrors', + 'i18n', 'ParseTypeChange', 'WorkflowJobTemplateModel', + function($scope, TemplatesService, JobTemplate, PromptService, Rest, $q, + TemplatesStrings, CreateSelect2, Empty, generateList, qs, + GetBasePath, TemplateList, ProjectList, InventorySourcesList, ProcessErrors, + i18n, ParseTypeChange, WorkflowJobTemplate + ) { + + let promptWatcher, credentialsWatcher, surveyQuestionWatcher, listPromises = []; + + $scope.strings = TemplatesStrings; + $scope.editNodeHelpMessage = null; + + 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.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 + }; + templateList.maxVisiblePages = 5; + templateList.searchBarFullWidth = true; + $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 }}"; + 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.name = 'wf_maker_projects'; + projectList.iterator = 'wf_maker_project'; + projectList.fields.name.columnClass = "col-md-11"; + projectList.maxVisiblePages = 5; + projectList.searchBarFullWidth = true; + projectList.disableRow = "{{ readOnly }}"; + projectList.disableRowValue = 'readOnly'; + $scope.projectList = projectList; + + 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, () => { + const templateType = _.get($scope, 'promptData.templateType'); + let missingPromptValue = false; + + if ($scope.missingSurveyValue) { + missingPromptValue = true; + } + + if (templateType !== "workflow_job_template") { + 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 finishConfiguringAdd = () => { + $scope.selectedTemplate = null; + $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', + multiple: false + }); + + $scope.nodeFormDataLoaded = true; + }; + + const getEditNodeHelpMessage = (selectedTemplate, workflowJobTemplateObj) => { + if (selectedTemplate) { + if (selectedTemplate.type === "workflow_job_template") { + if (workflowJobTemplateObj.inventory) { + if (selectedTemplate.ask_inventory_on_launch) { + return $scope.strings.get('workflow_maker.INVENTORY_WILL_OVERRIDE'); + } + } + + 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'); + } + } + } + + return null; + }; + + const finishConfiguringEdit = () => { + const ujt = _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject'); + const templateType = _.get(ujt, 'type'); + + $scope.editNodeHelpMessage = getEditNodeHelpMessage(ujt, $scope.workflowJobTemplateObj); + + if (!$scope.readOnly) { + 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); + 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 (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; + } + } + $scope.nodeFormDataLoaded = true; + } else if ( + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.unified_job_type') === 'job_template' || + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === 'job_template' || + _.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject.type') === 'workflow_job_template' + ) { + let promises = [jobTemplate.optionsLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id), jobTemplate.getLaunch($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id)]; + + 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.type === "job_template" && + ((!$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; + } + }); + + $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; + } else { + $scope.promptModalMissingReqFields = false; + } + + if (responses[1].data.survey_enabled) { + // go out and get the survey questions + jobTemplate.getSurveyQuestions($scope.nodeConfig.node.fullUnifiedJobTemplateObject.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + 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.nodeConfig.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + templateType: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.type, + 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; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + checkCredentialsForRequiredPasswords(); + + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + }); + } else { + $scope.nodeConfig.node.promptData = $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + templateType: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.type, + template: $scope.nodeConfig.node.fullUnifiedJobTemplateObject.id + }; + + checkCredentialsForRequiredPasswords(); + + watchForPromptChanges(); + + $scope.nodeFormDataLoaded = true; + } + } + }); + } else { + $scope.nodeFormDataLoaded = true; + } + + if (_.get($scope, 'nodeConfig.node.fullUnifiedJobTemplateObject')) { + $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 "workflow_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"; + } + } else { + $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; + } + + }; + + const templateManuallySelected = (selectedTemplate) => { + + if (promptWatcher) { + promptWatcher(); + } + + if (surveyQuestionWatcher) { + surveyQuestionWatcher(); + } + + if (credentialsWatcher) { + credentialsWatcher(); + } + + $scope.promptData = null; + $scope.editNodeHelpMessage = getEditNodeHelpMessage(selectedTemplate, $scope.workflowJobTemplateObj); + + 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; + + 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 && + !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; + $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) { + // 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, + 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: responses[1].data, + 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; + } + }; + + const setupNodeForm = () => { + $scope.nodeFormDataLoaded = false; + $scope.wf_maker_template_queryset = { + page_size: '10', + order_by: 'name', + role_level: 'execute_role', + type: 'workflow_job_template,job_template' + }; + + $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.wf_maker_template_queryset) + .then((res) => { + $scope.wf_maker_template_dataset = res.data; + $scope.wf_maker_templates = $scope.wf_maker_template_dataset.results; + }) + ); + + $scope.wf_maker_project_queryset = { + page_size: '10', + order_by: 'name' + }; + + $scope.wf_maker_projects = []; + $scope.wf_maker_project_dataset = {}; + + listPromises.push( + qs.search(GetBasePath('projects'), $scope.wf_maker_project_queryset) + .then((res) => { + $scope.wf_maker_project_dataset = res.data; + $scope.wf_maker_projects = $scope.wf_maker_project_dataset.results; + }) + ); + + $scope.wf_maker_inventory_source_dataset = { + page_size: '10', + order_by: 'name', + not__source: '' + }; + + $scope.wf_maker_inventory_sources = []; + $scope.wf_maker_inventory_source_dataset = {}; + + listPromises.push( + qs.search(GetBasePath('inventory_sources'), $scope.wf_maker_inventory_source_dataset) + .then((res) => { + $scope.wf_maker_inventory_source_dataset = res.data; + $scope.wf_maker_inventory_sources = $scope.wf_maker_inventory_source_dataset.results; + }) + ); + + $q.all(listPromises) + .then(() => { + if ($scope.nodeConfig.mode === "edit") { + // Make sure that we have the full unified job template object + if (!$scope.nodeConfig.node.fullUnifiedJobTemplateObject) { + // 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(({data}) => { + $scope.nodeConfig.node.fullUnifiedJobTemplateObject = data.results[0]; + finishConfiguringEdit(); + }, (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 { + finishConfiguringAdd(); + } + }); + }; + + $scope.openPromptModal = () => { + $scope.promptData.triggerModalOpen = true; + }; + + $scope.toggle_row = (selectedRow) => { + if (!$scope.readOnly) { + templateManuallySelected(selectedRow); + } + }; + + $scope.$watch('nodeConfig.nodeId', (newNodeId, oldNodeId) => { + if (newNodeId !== oldNodeId) { + setupNodeForm(); + } + }); + + $scope.$watchGroup(['wf_maker_templates', 'wf_maker_projects', 'wf_maker_inventory_sources', 'activeTab', 'selectedTemplate.id'], () => { + const unifiedJobTemplateId = _.get($scope, 'selectedTemplate.id') || null; + switch($scope.activeTab) { + case 'jobs': + $scope.wf_maker_templates.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { + $scope.wf_maker_templates[i].checked = 1; + } + else { + $scope.wf_maker_templates[i].checked = 0; + } + }); + break; + case 'project_syncs': + $scope.wf_maker_projects.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { + $scope.wf_maker_projects[i].checked = 1; + } + else { + $scope.wf_maker_projects[i].checked = 0; + } + }); + break; + case 'inventory_syncs': + $scope.wf_maker_inventory_sources.forEach((row, i) => { + if(row.id === unifiedJobTemplateId) { + $scope.wf_maker_inventory_sources[i].checked = 1; + } + else { + $scope.wf_maker_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 new file mode 100644 index 0000000000..ff16c0b2cc --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.directive.js @@ -0,0 +1,24 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import workflowNodeFormController from './workflow-node-form.controller'; + +export default ['templateUrl', + function(templateUrl) { + return { + scope: { + nodeConfig: '<', + workflowJobTemplateObj: '<', + cancel: '&', + select: '&', + readOnly: '<' + }, + restrict: 'E', + templateUrl: templateUrl('templates/workflows/workflow-maker/forms/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 new file mode 100644 index 0000000000..4811012465 --- /dev/null +++ b/awx/ui/client/src/templates/workflows/workflow-maker/forms/workflow-node-form.partial.html @@ -0,0 +1,245 @@ +
+
{{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')}}
+
+
+
+
+ + +
+ +
+
No records matched your search.
+
+
PLEASE ADD ITEMS TO THIS LIST
+
+ + + + + + + + + + + + + + + +
+ +
+ + + {{wf_maker_template.name}} + + {{:: strings.get('workflow_maker.WORKFLOW') }} + +
+
+ +
+
+
+ + +
+ +
+
No records matched your search.
+
+
No Projects Have Been Created
+
+ + + + + + + + + + + + + +
+
+ + + {{ wf_maker_project.name }}
+
+ +
+
+
+ + +
+ +
+
No records matched your search.
+
+
PLEASE ADD ITEMS TO THIS LIST
+
+ + + + + + + + + + + + + +
+
+ + + {{ wf_maker_inventory_source.name }} +
+
+ +
+
+
+
+ + {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} +
+
+
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+
+ +
+ +
+
+
+
+ {{:: 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/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..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 @@ -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,46 +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; - text-transform: uppercase; 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; @@ -165,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; @@ -202,6 +227,7 @@ margin: 10px 5px 10px 0px; line-height: 20px; } + .WorkflowLegend-details { align-items: center; display: flex; @@ -216,6 +242,7 @@ display: block; flex: 1 0 auto; } + .WorkflowLegend-details--right { flex: 0 0 44px; text-align: right; @@ -230,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; @@ -252,6 +282,7 @@ margin-left: -1px; border-right: 0; } + .WorkflowLegend-manualControls { position: absolute; left: -272px; @@ -263,18 +294,25 @@ 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; } +.WorkflowMaker-readOnlyPromptText { + margin-bottom: 20px; +} + .Key-list { margin: 0; padding: 20px; @@ -282,15 +320,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; } @@ -301,27 +342,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; @@ -331,6 +379,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 8a8258220b..5bb597d009 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 @@ -4,1220 +4,57 @@ * All Rights Reserved *************************************************/ -export default ['$scope', 'WorkflowService', 'TemplatesService', - 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel', - 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', '$state', - function ($scope, WorkflowService, TemplatesService, - ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate, - Empty, PromptService, Rest, TemplatesStrings, $timeout, $state) { - - let promptWatcher, surveyQuestionWatcher, credentialsWatcher; - - $scope.strings = TemplatesStrings; - $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 = []; - let credentialRequests = []; - - $scope.showKey = false; - $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'); - } - - function recursiveNodeUpdates(params, completionCallback) { - // params.parentId - // params.node - - let buildSendableNodeData = function () { - // Create the node - let sendableNodeData = { - unified_job_template: params.node.unifiedJobTemplate.id, - extra_data: {}, - inventory: null, - job_type: null, - job_tags: null, - skip_tags: null, - limit: null, - diff_mode: null, - verbosity: null, - credential: null - }; - - if (_.has(params, 'node.promptData.extraVars')) { - if (_.get(params, 'node.promptData.launchConf.defaults.extra_vars')) { - const defaultVars = jsyaml.safeLoad(params.node.promptData.launchConf.defaults.extra_vars); - - // Only include extra vars that differ from the template default vars - _.forOwn(params.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(params, 'node.promptData.extraVars') && !_.isEmpty(params.node.promptData.extraVars)) { - sendableNodeData.extra_data = params.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 ((params.node.unifiedJobTemplate.type === "job_template" || params.node.unifiedJobTemplate.type === "workflow_job_template") && params.node.promptData) { - sendableNodeData = PromptService.bundlePromptDataForSaving({ - promptData: params.node.promptData, - dataToSave: sendableNodeData - }); - } - - return sendableNodeData; - }; - - let continueRecursing = function (parentId) { - $scope.totalIteratedNodes++; - - if ($scope.totalIteratedNodes === $scope.treeData.data.totalNodes) { - // We're done recursing, lets move on - completionCallback(); - } else { - if (params.node.children && params.node.children.length > 0) { - _.forEach(params.node.children, function (child) { - if (child.edgeType === "success") { - recursiveNodeUpdates({ - parentId: parentId, - node: child - }, completionCallback); - } else if (child.edgeType === "failure") { - recursiveNodeUpdates({ - parentId: parentId, - node: child - }, completionCallback); - } else if (child.edgeType === "always") { - recursiveNodeUpdates({ - parentId: parentId, - node: child - }, completionCallback); - } - }); - } - } - }; - - if (params.node.isNew) { - - TemplatesService.addWorkflowNode({ - url: $scope.treeData.workflow_job_template_obj.related.workflow_nodes, - data: buildSendableNodeData() - }) - .then(function (data) { - - if (!params.node.isRoot) { - associateRequests.push({ - parentId: params.parentId, - nodeId: data.data.id, - edge: params.node.edgeType - }); - } - - 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 - } - }); - }); - } - - params.node.isNew = false; - continueRecursing(data.data.id); - }, function ({ - data, - config, - status - }) { - 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 { - if (params.node.edited || !params.node.originalParentId || (params.node.originalParentId && params.parentId !== params.node.originalParentId)) { - - if (params.node.edited) { - - editRequests.push({ - id: params.node.nodeId, - data: buildSendableNodeData() - }); - - if (_.get(params, 'node.promptData.launchConf.ask_credential_on_launch')) { - let credentialsNotInPriorCredentials = 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; - }); - }); - - let credentialsToAdd = credentialsNotInPriorCredentials.filter(function (credNotInPrior) { - let previousOverrides = _.get(params, 'node.promptData.prompts.credentials.previousOverrides', []); - return !previousOverrides.some(function (priorCred) { - return credNotInPrior.id === priorCred.id; - }); - }); - - let credentialsToRemove = []; - - if (_.has(params, 'node.promptData.prompts.credentials.previousOverrides')) { - credentialsToRemove = params.node.promptData.prompts.credentials.previousOverrides.filter(function (priorCred) { - return !credentialsNotInPriorCredentials.some(function (credNotInPrior) { - return priorCred.id === credNotInPrior.id; - }); - }); - } - - credentialsToAdd.forEach((credentialToAdd) => { - credentialRequests.push({ - id: params.node.nodeId, - data: { - id: credentialToAdd.id - } - }); - }); - - credentialsToRemove.forEach((credentialToRemove) => { - credentialRequests.push({ - id: params.node.nodeId, - data: { - id: credentialToRemove.id, - disassociate: true - } - }); - }); - } - } - - if (params.node.originalParentId && (params.parentId !== params.node.originalParentId || params.node.originalEdge !== params.node.edgeType)) { - let parentIsDeleted = false; - - _.forEach($scope.treeData.data.deletedNodes, function (deletedNode) { - if (deletedNode === params.node.originalParentId) { - parentIsDeleted = true; - } - }); - - if (!parentIsDeleted) { - disassociateRequests.push({ - parentId: params.node.originalParentId, - nodeId: params.node.nodeId, - edge: params.node.originalEdge - }); - } - - // Can only associate if we have a parent. - // If we don't have a parent then this is a root node - // and the act of disassociating will make it a root node - if (params.parentId) { - associateRequests.push({ - parentId: params.parentId, - nodeId: params.node.nodeId, - edge: params.node.edgeType - }); - } - - } else if (!params.node.originalParentId && params.parentId) { - // This used to be a root node but is now not a root node - associateRequests.push({ - parentId: params.parentId, - nodeId: params.node.nodeId, - edge: params.node.edgeType - }); - } - - } - - continueRecursing(params.node.nodeId); - } - } - - 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 () { - // Revert the data to the master which was created when the dialog was opened - $scope.treeData.data = angular.copy($scope.treeDataMaster); - $scope.closeDialog(); - }; - - $scope.saveWorkflowMaker = function () { - - $scope.totalIteratedNodes = 0; - - if ($scope.treeData && $scope.treeData.data && $scope.treeData.data.children && $scope.treeData.data.children.length > 0) { - let completionCallback = function () { - - let disassociatePromises = disassociateRequests.map(function (request) { - return TemplatesService.disassociateWorkflowNode({ - parentId: request.parentId, - nodeId: request.nodeId, - edge: request.edge - }); - }); - - let editNodePromises = editRequests.map(function (request) { - return TemplatesService.editWorkflowNode({ - id: request.id, - data: request.data - }); - }); - - let deletePromises = $scope.treeData.data.deletedNodes.map(function (nodeId) { - return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); - }); - - $q.all(disassociatePromises.concat(editNodePromises, deletePromises)) - .then(function () { - - let credentialPromises = credentialRequests.map(function (request) { - return TemplatesService.postWorkflowNodeCredential({ - id: request.id, - data: request.data - }); - }); - - let associatePromises = associateRequests.map(function (request) { - return TemplatesService.associateWorkflowNode({ - parentId: request.parentId, - nodeId: request.nodeId, - edge: request.edge - }); - }); - - return $q.all(associatePromises.concat(credentialPromises)) - .then(function () { - $scope.closeDialog(); - $state.transitionTo('templates'); - }); - }).catch(({ - data, - status - }) => { - ProcessErrors($scope, data, status, null, {}); - }); - }; - - _.forEach($scope.treeData.data.children, function (child) { - recursiveNodeUpdates({ - node: child - }, completionCallback); - }); - } else { - - let deletePromises = $scope.treeData.data.deletedNodes.map(function (nodeId) { - return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); - }); - - $q.all(deletePromises) - .then(function () { - $scope.closeDialog(); - $state.transitionTo('templates'); - }); - } - }; - - /* ADD NODE FUNCTIONS */ - - $scope.startAddNode = function (parent, betweenTwoNodes) { - - if ($scope.placeholderNode || $scope.nodeBeingEdited) { - $scope.cancelNodeForm(); - } - - $scope.workflowMakerFormConfig.nodeMode = "add"; - $scope.addParent = parent; - $scope.betweenTwoNodes = betweenTwoNodes; - - $scope.placeholderNode = WorkflowService.addPlaceholderNode({ - parent: parent, - betweenTwoNodes: betweenTwoNodes, - tree: $scope.treeData.data, - id: $scope.treeData.nextIndex - }); - - $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.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.placeholderNode.canEdit = true; - - delete $scope.placeholderNode.placeholder; - - resetNodeForm(); - - // 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); - } - - $scope.nodeBeingEdited.isActiveEdit = false; - - $scope.nodeBeingEdited.edited = true; - - resetNodeForm(); - } - } - - if (promptWatcher) { - promptWatcher(); - } - - if (surveyQuestionWatcher) { - surveyQuestionWatcher(); - } - - if (credentialsWatcher) { - credentialsWatcher(); - } - - $scope.promptData = null; - - $scope.$broadcast("refreshWorkflowChart"); - }; - - $scope.cancelNodeForm = function () { - if ($scope.workflowMakerFormConfig.nodeMode === "add") { - // Remove the placeholder node from the tree - WorkflowService.removeNodeFromTree({ - tree: $scope.treeData.data, - nodeToBeDeleted: $scope.placeholderNode - }); - } else if ($scope.workflowMakerFormConfig.nodeMode === "edit") { - $scope.nodeBeingEdited.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.$broadcast("refreshWorkflowChart"); - }; - - /* EDIT NODE FUNCTIONS */ - - $scope.startEditNode = function (nodeToEdit) { - $scope.editNodeHelpMessage = null; - - if (!$scope.nodeBeingEdited || ($scope.nodeBeingEdited && $scope.nodeBeingEdited.id !== nodeToEdit.id)) { - if ($scope.placeholderNode || $scope.nodeBeingEdited) { - $scope.cancelNodeForm(); - - // Refresh this object as the parent has changed - nodeToEdit = WorkflowService.searchTree({ - element: $scope.treeData.data, - matchingId: nodeToEdit.id - }); - } - - $scope.workflowMakerFormConfig.nodeMode = "edit"; - - let parent = WorkflowService.searchTree({ - element: $scope.treeData.data, - matchingId: nodeToEdit.parent.id - }); - - $scope.nodeBeingEdited = 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(); - } - - } - - }; - - /* DELETE NODE FUNCTIONS */ - - function resetDeleteNode() { - $scope.nodeToBeDeleted = null; - $scope.deleteOverlayVisible = false; - } - - $scope.startDeleteNode = function (nodeToDelete) { - $scope.nodeToBeDeleted = nodeToDelete; - $scope.deleteOverlayVisible = true; - }; - - $scope.cancelDeleteNode = function () { - resetDeleteNode(); - }; - - $scope.confirmDeleteNode = function () { - if ($scope.nodeToBeDeleted) { - - // TODO: turn this into a promise so that we can handle errors - - WorkflowService.removeNodeFromTree({ - tree: $scope.treeData.data, - nodeToBeDeleted: $scope.nodeToBeDeleted - }); - - if ($scope.nodeToBeDeleted.isNew !== true) { - $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.showManualControls = !$scope.showManualControls; - }; - - $scope.panChart = function (direction) { - $scope.$broadcast('panWorkflowChart', { - direction: direction - }); - }; - - $scope.zoomChart = function (zoom) { - $scope.$broadcast('zoomWorkflowChart', { - zoom: zoom - }); - }; - - $scope.resetChart = function () { - $scope.$broadcast('resetWorkflowChart'); - }; - - $scope.workflowZoomed = function (zoom) { - $scope.$broadcast('workflowZoomed', { - zoom: zoom - }); - }; - - $scope.zoomToFitChart = function () { - $scope.$broadcast('zoomToFitChart'); - }; - - $scope.openPromptModal = function () { - $scope.promptData.triggerModalOpen = true; - }; - +export default ['$scope', 'TemplatesService', + 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', + 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', 'WorkflowChartService', + 'Wait', '$state', + function ($scope, TemplatesService, + ProcessErrors, CreateSelect2, $q, JobTemplate, + Empty, PromptService, Rest, TemplatesStrings, WorkflowChartService, + Wait, $state + ) { + + let deletedNodeIds = []; + let workflowMakerNodeIdCounter; + let nodeIdToChartNodeIdMapping = {}; + let nodeRef = {}; let allNodes = []; let page = 1; - let buildTreeFromNodes = function () { - WorkflowService.buildTree({ - workflowNodes: allNodes - }).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 = []; - } - - $scope.treeData.workflow_job_template_obj = $scope.workflowJobTemplateObj; - - $scope.treeDataMaster = angular.copy($scope.treeData.data); - $scope.showManualControls = false; - }); + $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 getNodes = function () { - // Get the workflow nodes + let getNodes = () => { + 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]); + .then(({data}) => { + for (let i = 0; i < data.results.length; i++) { + allNodes.push(data.results[i]); } - if (data.data.next) { + if (data.next) { // Get the next page page++; getNodes(); } else { - // This is the last page - buildTreeFromNodes(); + let arrayOfLinksForChart = []; + let arrayOfNodesForChart = []; + + ({arrayOfNodesForChart, arrayOfLinksForChart, nodeIdToChartNodeIdMapping, nodeRef, workflowMakerNodeIdCounter} = WorkflowChartService.generateArraysOfNodesAndLinks(allNodes)); + + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; + + Wait('stop'); } - }, function ({ - data, - status, - config - }) { + }, ({ data, status, config }) => { + Wait('stop'); ProcessErrors($scope, data, status, null, { hdr: $scope.strings.get('error.HEADER'), msg: $scope.strings.get('error.CALL', { @@ -1231,6 +68,859 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', getNodes(); - updateEdgeDropdownOptions(); + $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 === "workflow_job_template" || + 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 = []; + let credentialRequests = []; + + Object.keys(nodeRef).map((workflowMakerNodeId) => { + if (nodeRef[workflowMakerNodeId].isNew) { + addPromises.push(TemplatesService.addWorkflowNode({ + url: $scope.workflowJobTemplateObj.related.workflow_nodes, + data: buildSendableNodeData(nodeRef[workflowMakerNodeId]) + }).then(({data}) => { + nodeRef[workflowMakerNodeId].originalNodeObject = data; + nodeIdToChartNodeIdMapping[data.id] = parseInt(workflowMakerNodeId); + 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((credFromPrompt) => { + let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); + return !defaultCreds.some((defaultCred) => { + return credFromPrompt.id === defaultCred.id; + }); + }); + + credentialIdsToPost.forEach((credentialToPost) => { + credentialRequests.push({ + id: data.data.id, + data: { + id: credentialToPost.id + } + }); + }); + } + })); + } else if (nodeRef[workflowMakerNodeId].isEdited) { + editPromises.push(TemplatesService.editWorkflowNode({ + 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((credFromPrompt) => { + let defaultCreds = _.get(nodeRef[workflowMakerNodeId], 'promptData.launchConf.defaults.credentials', []); + return !defaultCreds.some((defaultCred) => { + return credFromPrompt.id === defaultCred.id; + }); + }); + + let credentialsToAdd = credentialsNotInPriorCredentials.filter((credNotInPrior) => { + let previousOverrides = _.get(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides', []); + return !previousOverrides.some((priorCred) => { + return credNotInPrior.id === priorCred.id; + }); + }); + + let credentialsToRemove = []; + + if (_.has(nodeRef[workflowMakerNodeId], 'promptData.prompts.credentials.previousOverrides')) { + credentialsToRemove = nodeRef[workflowMakerNodeId].promptData.prompts.credentials.previousOverrides.filter((priorCred) => { + return !credentialsNotInPriorCredentials.some((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 + } + }); + }); + } + } + + }); + + let deletePromises = deletedNodeIds.map((nodeId) => { + return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); + }); + + $q.all(addPromises.concat(editPromises, deletePromises)) + .then(() => { + let disassociatePromises = []; + let associatePromises = []; + let linkMap = {}; + + // Build a link map for easy access + $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]) { + linkMap[sourceNodeId] = {}; + } + + linkMap[sourceNodeId][targetNodeId] = link.edgeType; + } + }); + + Object.keys(nodeRef).map((workflowNodeId) => { + let nodeId = nodeRef[workflowNodeId].originalNodeObject.id; + if (nodeRef[workflowNodeId].originalNodeObject.success_nodes) { + nodeRef[workflowNodeId].originalNodeObject.success_nodes.forEach((successNodeId) => { + if ( + !deletedNodeIds.includes(successNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][successNodeId] || + linkMap[nodeId][successNodeId] !== "success") + ) { + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: successNodeId, + edge: "success" + }) + ); + } + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.failure_nodes) { + nodeRef[workflowNodeId].originalNodeObject.failure_nodes.forEach((failureNodeId) => { + if ( + !deletedNodeIds.includes(failureNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][failureNodeId] || + linkMap[nodeId][failureNodeId] !== "failure") + ) { + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: failureNodeId, + edge: "failure" + }) + ); + } + }); + } + if (nodeRef[workflowNodeId].originalNodeObject.always_nodes) { + nodeRef[workflowNodeId].originalNodeObject.always_nodes.forEach((alwaysNodeId) => { + if ( + !deletedNodeIds.includes(alwaysNodeId) && + (!linkMap[nodeId] || + !linkMap[nodeId][alwaysNodeId] || + linkMap[nodeId][alwaysNodeId] !== "always") + ) { + disassociatePromises.push( + TemplatesService.disassociateWorkflowNode({ + parentId: nodeId, + nodeId: alwaysNodeId, + edge: "always" + }) + ); + } + }); + } + }); + + Object.keys(linkMap).map((sourceNodeId) => { + Object.keys(linkMap[sourceNodeId]).map((targetNodeId) => { + const sourceChartNodeId = nodeIdToChartNodeIdMapping[sourceNodeId]; + const targetChartNodeId = nodeIdToChartNodeIdMapping[targetNodeId]; + switch(linkMap[sourceNodeId][targetNodeId]) { + case "success": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.success_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "success" + }) + ); + } + break; + case "failure": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.failure_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "failure" + }) + ); + } + break; + case "always": + if ( + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes || + !nodeRef[sourceChartNodeId].originalNodeObject.always_nodes.includes(nodeRef[targetChartNodeId].originalNodeObject.id) + ) { + associatePromises.push( + TemplatesService.associateWorkflowNode({ + parentId: parseInt(sourceNodeId), + nodeId: parseInt(targetNodeId), + edge: "always" + }) + ); + } + break; + } + }); + }); + + $q.all(disassociatePromises) + .then(() => { + let credentialPromises = credentialRequests.map((request) => { + return TemplatesService.postWorkflowNodeCredential({ + id: request.id, + data: request.data + }); + }); + + return $q.all(associatePromises.concat(credentialPromises)) + .then(() => { + Wait('stop'); + $scope.closeDialog(); + }); + }).catch(({ + data, + status + }) => { + Wait('stop'); + ProcessErrors($scope, data, status, null, {}); + }); + }); + + } else { + + let deletePromises = deletedNodeIds.map((nodeId) => { + return TemplatesService.deleteWorkflowJobTemplateNode(nodeId); + }); + + $q.all(deletePromises) + .then(() => { + Wait('stop'); + $scope.closeDialog(); + $state.transitionTo('templates'); + }); + } + }; + + /* ADD NODE FUNCTIONS */ + + $scope.startAddNodeWithoutChild = (parent) => { + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } + + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + + $scope.graphState.arrayOfNodesForChart.push({ + id: workflowMakerNodeIdCounter, + unifiedJobTemplate: null + }); + + $scope.graphState.nodeBeingAdded = workflowMakerNodeIdCounter; + + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: parent.id}, + target: {id: workflowMakerNodeIdCounter}, + edgeType: "placeholder" + }); + + $scope.nodeConfig = { + mode: "add", + nodeId: workflowMakerNodeIdCounter, + newNodeIsRoot: parent.id === 1 + }; + + workflowMakerNodeIdCounter++; + + $scope.$broadcast("refreshWorkflowChart"); + + $scope.formState.showNodeForm = true; + }; + + $scope.startAddNodeWithChild = (link) => { + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } + + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + + $scope.graphState.arrayOfNodesForChart.push({ + id: workflowMakerNodeIdCounter, + unifiedJobTemplate: null + }); + + $scope.graphState.nodeBeingAdded = workflowMakerNodeIdCounter; + + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: link.source.id}, + target: {id: workflowMakerNodeIdCounter}, + edgeType: "placeholder" + }); + + $scope.nodeConfig = { + mode: "add", + nodeId: workflowMakerNodeIdCounter, + 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.graphState.arrayOfLinksForChart.forEach((linkToCompare) => { + if (linkToCompare.source.id === link.source.id && linkToCompare.target.id === link.target.id) { + linkToCompare.source = {id: workflowMakerNodeIdCounter}; + } + }); + + workflowMakerNodeIdCounter++; + + $scope.$broadcast("refreshWorkflowChart"); + + $scope.formState.showNodeForm = true; + }; + + $scope.confirmNodeForm = (selectedTemplate, promptData, edgeType) => { + const nodeId = $scope.nodeConfig.nodeId; + if ($scope.nodeConfig.mode === "add") { + if (selectedTemplate && edgeType && edgeType.value) { + nodeRef[$scope.nodeConfig.nodeId] = { + fullUnifiedJobTemplateObject: selectedTemplate, + promptData, + isNew: true + }; + + $scope.graphState.nodeBeingAdded = null; + + $scope.graphState.arrayOfLinksForChart.map( (link) => { + if (link.target.id === nodeId) { + link.edgeType = edgeType.value; + } + }); + } + } else if ($scope.nodeConfig.mode === "edit") { + if (selectedTemplate) { + nodeRef[$scope.nodeConfig.nodeId].fullUnifiedJobTemplateObject = selectedTemplate; + nodeRef[$scope.nodeConfig.nodeId].promptData = _.cloneDeep(promptData); + nodeRef[$scope.nodeConfig.nodeId].isEdited = true; + $scope.graphState.nodeBeingEdited = null; + } + } + + $scope.graphState.arrayOfNodesForChart.map( (node) => { + if (node.id === nodeId) { + node.unifiedJobTemplate = selectedTemplate; + } + }); + + $scope.formState.showNodeForm = false; + $scope.nodeConfig = null; + + $scope.$broadcast("refreshWorkflowChart"); + }; + + $scope.cancelNodeForm = () => { + const nodeId = $scope.nodeConfig.nodeId; + if ($scope.nodeConfig.mode === "add") { + // Remove the placeholder node from the array + 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 = []; + + // Remove any links that reference this node + for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ + const link = $scope.graphState.arrayOfLinksForChart[i]; + + 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); + } + } + + // Add the new links + parents.forEach((parentId) => { + children.forEach((child) => { + let source = { + id: parentId + }; + if (parentId === 1) { + child.edgeType = "always"; + } + $scope.graphState.arrayOfLinksForChart.push({ + source, + target: {id: child.id}, + edgeType: child.edgeType + }); + }); + }); + + } else if ($scope.nodeConfig.mode === "edit") { + $scope.graphState.nodeBeingEdited = null; + } + $scope.formState.showNodeForm = false; + $scope.nodeConfig = null; + $scope.$broadcast("refreshWorkflowChart"); + }; + + /* EDIT NODE FUNCTIONS */ + + $scope.startEditNode = (nodeToEdit) => { + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + + if (!$scope.nodeConfig || ($scope.nodeConfig && $scope.nodeConfig.nodeId !== nodeToEdit.id)) { + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } + + $scope.nodeConfig = { + mode: "edit", + nodeId: nodeToEdit.id, + node: nodeRef[nodeToEdit.id] + }; + + $scope.graphState.nodeBeingEdited = nodeToEdit.id; + + $scope.formState.showNodeForm = true; + } + + $scope.$broadcast("refreshWorkflowChart"); + }; + + /* LINK FUNCTIONS */ + + $scope.startEditLink = (linkToEdit) => { + const setupLinkEdit = () => { + + // Determine whether or not this link can be removed + let numberOfParents = 0; + $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", + source: { + id: linkToEdit.source.id, + name: _.get(linkToEdit, 'source.unifiedJobTemplate.name') || "" + }, + target: { + id: linkToEdit.target.id, + name: _.get(linkToEdit, 'target.unifiedJobTemplate.name') || "" + }, + edgeType: linkToEdit.edgeType, + canUnlink: numberOfParents > 1 + }; + $scope.formState.showLinkForm = true; + + $scope.$broadcast("refreshWorkflowChart"); + }; + + if ($scope.nodeConfig) { + $scope.cancelNodeForm(); + } + + if ($scope.linkConfig) { + 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); + } + setupLinkEdit(); + } + } else { + setupLinkEdit(); + } + + }; + + $scope.selectNodeForLinking = (node) => { + 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.target = { + id: node.id, + name: node.unifiedJobTemplate.name + }; + $scope.linkConfig.edgeType = "success"; + + $scope.graphState.arrayOfNodesForChart.forEach((nodeToUpdate) => { + nodeToUpdate.isInvalidLinkTarget = false; + }); + + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: $scope.linkConfig.source.id}, + target: {id: node.id}, + edgeType: "placeholder" + }); + + $scope.graphState.linkBeingEdited = { + source: {id: $scope.linkConfig.source.id}, + target: {id: node.id} + }; + + $scope.graphState.arrayOfLinksForChart.forEach((link, index) => { + if (link.source.id === 1 && link.target.id === node.id) { + $scope.graphState.arrayOfLinksForChart.splice(index, 1); + } + }); + + $scope.graphState.isLinkMode = false; + } else { + // This is the first node selected + $scope.graphState.addLinkSource = node.id; + $scope.linkConfig = { + mode: "add", + source: { + id: node.id, + name: node.unifiedJobTemplate.name + } + }; + + let parentMap = {}; + let invalidLinkTargetIds = []; + + // Find and mark any ancestors as disabled to prevent cycles + $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) { + // Disables direct children from the add link process + invalidLinkTargetIds.push(link.target.id); + } + if (!parentMap[link.target.id]) { + parentMap[link.target.id] = []; + } + parentMap[link.target.id].push(link.source.id); + } + }); + + let getAncestors = (id) => { + if (parentMap[id]) { + parentMap[id].forEach((parentId) => { + invalidLinkTargetIds.push(parentId); + getAncestors(parentId); + }); + } + }; + + getAncestors(node.id); + + // Filter out the duplicates + invalidLinkTargetIds.filter((element, index, array) => index === array.indexOf(element)).forEach((ancestorId) => { + $scope.graphState.arrayOfNodesForChart.forEach((node) => { + if (node.id === ancestorId) { + node.isInvalidLinkTarget = true; + } + }); + }); + + $scope.graphState.isLinkMode = true; + + $scope.formState.showLinkForm = true; + } + + $scope.$broadcast("refreshWorkflowChart"); + }; + + $scope.confirmLinkForm = (newEdgeType) => { + $scope.graphState.arrayOfLinksForChart.forEach((link) => { + if (link.source.id === $scope.linkConfig.source.id && link.target.id === $scope.linkConfig.target.id) { + link.edgeType = newEdgeType; + } + }); + + if ($scope.linkConfig.mode === "add") { + $scope.graphState.arrayOfNodesForChart.forEach((node) => { + node.isInvalidLinkTarget = false; + }); + } + + $scope.graphState.linkBeingEdited = null; + $scope.graphState.addLinkSource = null; + $scope.formState.showLinkForm = false; + $scope.linkConfig = null; + $scope.$broadcast("refreshWorkflowChart"); + }; + + $scope.unlink = () => { + // Remove the link + for( let i = $scope.graphState.arrayOfLinksForChart.length; i--; ){ + const link = $scope.graphState.arrayOfLinksForChart[i]; + + if (link.source.id === $scope.linkConfig.source.id && link.target.id === $scope.linkConfig.target.id) { + $scope.graphState.arrayOfLinksForChart.splice(i, 1); + } + } + + $scope.formState.showLinkForm = false; + $scope.linkConfig = null; + $scope.$broadcast("refreshWorkflowChart"); + }; + + $scope.cancelLinkForm = () => { + 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.target.id) { + targetIsOrphaned = false; + } + }); + if (targetIsOrphaned) { + // Link it to the start node + $scope.graphState.arrayOfLinksForChart.push({ + source: {id: 1}, + target: {id: $scope.linkConfig.target.id}, + edgeType: "always" + }); + } + } + $scope.graphState.linkBeingEdited = null; + $scope.graphState.addLinkSource = null; + $scope.graphState.isLinkMode = false; + $scope.graphState.arrayOfNodesForChart.forEach((node) => { + node.isInvalidLinkTarget = false; + }); + $scope.formState.showLinkForm = false; + $scope.linkConfig = null; + $scope.$broadcast("refreshWorkflowChart"); + }; + + /* DELETE NODE FUNCTIONS */ + + $scope.startDeleteNode = (nodeToDelete) => { + $scope.nodeToBeDeleted = nodeToDelete; + $scope.deleteOverlayVisible = true; + }; + + $scope.cancelDeleteNode = () => { + $scope.nodeToBeDeleted = null; + $scope.deleteOverlayVisible = false; + }; + + $scope.confirmDeleteNode = () => { + if ($scope.nodeToBeDeleted) { + const nodeId = $scope.nodeToBeDeleted.id; + + if ($scope.linkConfig) { + $scope.cancelLinkForm(); + } + + // Remove the node from the array + 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 (!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); + } + } + + // Add the new links + parents.forEach((parentId) => { + children.forEach((child) => { + 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 + }); + } + } + + }); + }); + + if (nodeRef[$scope.nodeToBeDeleted.id].isNew !== true) { + deletedNodeIds.push(nodeRef[$scope.nodeToBeDeleted.id].originalNodeObject.id); + } + + delete nodeRef[$scope.nodeToBeDeleted.id]; + + $scope.deleteOverlayVisible = false; + + $scope.nodeToBeDeleted = null; + $scope.deleteOverlayVisible = false; + + $scope.$broadcast("refreshWorkflowChart"); + } + + }; + + $scope.toggleManualControls = () => { + $scope.showManualControls = !$scope.showManualControls; + }; + + $scope.panChart = (direction) => { + $scope.$broadcast('panWorkflowChart', { + direction: direction + }); + }; + + $scope.zoomChart = (zoom) => { + $scope.$broadcast('zoomWorkflowChart', { + zoom: zoom + }); + }; + + $scope.resetChart = () => { + $scope.$broadcast('resetWorkflowChart'); + }; + + $scope.workflowZoomed = (zoom) => { + $scope.$broadcast('workflowZoomed', { + zoom: zoom + }); + }; + + $scope.zoomToFitChart = () => { + $scope.$broadcast('zoomToFitChart'); + }; } ]; 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..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 @@ -28,7 +28,7 @@
{{strings.get('workflow_maker.TITLE')}} | {{ workflowJobTemplateObj.name }}
-
@@ -73,81 +73,40 @@
- {{strings.get('workflow_maker.TOTAL_TEMPLATES')}} - + {{strings.get('workflow_maker.TOTAL_NODES')}} +
- + +
-
{{(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/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/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/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; +} 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..0ec32ad69c 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -1,11 +1,16 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', 'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange', - 'ParseVariableString', 'WorkflowService', 'count', '$state', 'i18n', - 'moment', '$filter', function(workflowData, workflowResultsService, + 'ParseVariableString', 'count', '$state', 'i18n', 'WorkflowChartService', '$filter', + 'moment', function(workflowData, workflowResultsService, workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, - ParseVariableString, WorkflowService, count, $state, i18n, moment, $filter) { + ParseVariableString, count, $state, i18n, WorkflowChartService, $filter, + moment) { + 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') { @@ -73,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: { @@ -113,11 +118,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 +169,15 @@ 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; + let arrayOfLinksForChart = []; + let arrayOfNodesForChart = []; - // 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 = []; - } - - $scope.canAddWorkflowJobTemplate = false; - }); + ({arrayOfNodesForChart, arrayOfLinksForChart, nodeRef} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes)); + $scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart }; } $scope.toggleStdoutFullscreen = function() { @@ -285,23 +277,16 @@ 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.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 }; } }); - $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..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}} @@ -363,7 +363,14 @@
- + + 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) { 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", diff --git a/awx/ui/test/e2e/tests/test-workflow-visualizer.js b/awx/ui/test/e2e/tests/test-workflow-visualizer.js index 7cccd3aec3..c9347dd165 100644 --- a/awx/ui/test/e2e/tests/test-workflow-visualizer.js +++ b/awx/ui/test/e2e/tests/test-workflow-visualizer.js @@ -14,31 +14,39 @@ const workflowSearchBar = "//input[contains(@class, 'SmartSearch-input')]"; const workflowText = 'name.iexact:"test-actions-workflow-template"'; const workflowSearchBadgeCount = '//span[contains(@class, "at-Panel-headingTitleBadge") and contains(text(), "1")]'; -const rootNode = "//*[@id='node-2']"; -const childNode = "//*[@id='node-3']"; -const newChildNode = "//*[@id='node-5']"; -const leafNode = "//*[@id='node-6']"; -const nodeAdd = "//*[contains(@class, 'nodeAddCross')]"; -const nodeRemove = "//*[contains(@class, 'nodeRemoveCross')]"; +const startNodeId = '1'; +let initialJobNodeId; +let initialProjectNodeId; +let initialInventoryNodeId; +let newChildNodeId; +let leafNodeId; +const nodeAdd = "//*[contains(@class, 'WorkflowChart-nodeAddIcon')]"; +const nodeRemove = "//*[contains(@class, 'WorkflowChart-nodeRemoveIcon')]"; // one of the jobs or projects or inventories -const testActionsProject = "//td[contains(text(), 'test-actions-project')]"; const testActionsJob = "//td[contains(text(), 'test-actions-job')]"; -const testActionsProjectText = 'name.iexact:"test-actions-project"'; const testActionsJobText = 'name.iexact:"test-actions-job-template"'; // search bar for visualizer templates const jobSearchBar = "//*[contains(@id, 'workflow-jobs-list')]//input[contains(@class, 'SmartSearch-input')]"; -const projectSearchBar = "//*[contains(@id, 'workflow-project-sync-list')]//input[contains(@class, 'SmartSearch-input')]"; // dropdown bar which lets you select edge type const edgeTypeDropdownBar = "//span[contains(@id, 'select2-workflow_node_edge-container')]"; const alwaysDropdown = "//li[contains(@id, 'select2-workflow_node_edge') and text()='Always']"; const successDropdown = "//li[contains(@id, 'select2-workflow_node_edge') and text()='On Success']"; const failureDropdown = "//li[contains(@id, 'select2-workflow_node_edge') and text()='On Failure']"; -const selectButton = "//*[@id='workflow_maker_select_btn']"; +const linkEdgeTypeDropdownBar = "//span[contains(@id, 'select2-workflow_link_edge-container')]"; +const linkAlwaysDropdown = "//li[contains(@id, 'select2-workflow_link_edge') and text()='Always']"; +const linkSuccessDropdown = "//li[contains(@id, 'select2-workflow_link_edge') and text()='On Success']"; +const linkFailureDropdown = "//li[contains(@id, 'select2-workflow_link_edge') and text()='On Failure']"; +const nodeSelectButton = "//*[@id='workflow_maker_select_node_btn']"; +const linkSelectButton = "//*[@id='workflow_maker_select_link_btn']"; +const nodeCancelButton = "//*[@id='workflow_maker_cancel_node_btn']"; const deleteConfirmation = "//button[@ng-click='confirmDeleteNode()']"; +const xPathNodeById = (id) => `//*[@id='node-${id}']`; +const xPathLinkById = (sourceId, targetId) => `//*[@id='link-${sourceId}-${targetId}']//*[contains(@class, 'WorkflowChart-linkPath')]`; + module.exports = { before: (client, done) => { const resources = [ @@ -66,44 +74,66 @@ module.exports = { .waitForElementVisible(workflowSearchBadgeCount) .waitForElementNotVisible(spinny) .findThenClick(workflowSelector) - .findThenClick(workflowVisualizerBtn); + .findThenClick(workflowVisualizerBtn) + .waitForElementVisible('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-job")]/..'); + + // Grab the ids of the nodes + client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-job")]/..', 'id', (res) => { + initialJobNodeId = res.value.split('-')[1]; + }); + client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-project")]/..', 'id', (res) => { + initialProjectNodeId = res.value.split('-')[1]; + }); + client.getAttribute('//*[contains(@class, "WorkflowChart-nameText") and contains(text(), "test-actions-inventory")]/..', 'id', (res) => { + initialInventoryNodeId = res.value.split('-')[1]; + }); }, - 'verify that workflow visualizer root node can only be set to always': client => { + 'verify that workflow visualizer new root node can only be set to always': client => { client .useXpath() - .findThenClick(rootNode) - .clearValue(projectSearchBar) - .setValue(projectSearchBar, [testActionsProjectText, client.Keys.ENTER]) - .pause(1000) - .findThenClick(testActionsProject) + .findThenClick(xPathNodeById(startNodeId)) + .waitForElementPresent(edgeTypeDropdownBar) .findThenClick(edgeTypeDropdownBar) .waitForElementNotPresent(successDropdown) .waitForElementNotPresent(failureDropdown) - .waitForElementPresent(alwaysDropdown); - }, - 'verify that a non-root node can be set to always/success/failure': client => { - client - .useXpath() - .findThenClick(childNode) - .pause(1000) - .waitForElementNotVisible(spinny) - .findThenClick(edgeTypeDropdownBar) - .waitForElementPresent(successDropdown) - .waitForElementPresent(failureDropdown) .waitForElementPresent(alwaysDropdown) - .findThenClick(edgeTypeDropdownBar); + .click(nodeCancelButton) + // Make sure that the animation finishes before moving on to the next test + .pause(500); }, - 'verify that a sibling node can be any edge type': client => { + 'verify that a link can be changed': client => { client .useXpath() - .moveToElement(childNode, 0, 0, () => { + .moveToElement(xPathLinkById(initialJobNodeId, initialInventoryNodeId), 20, 0, () => { + client.waitForElementNotVisible(spinny); + client.mouseButtonClick(0); + }) + .waitForElementPresent(linkEdgeTypeDropdownBar) + .findThenClick(linkEdgeTypeDropdownBar) + .waitForElementPresent(linkSuccessDropdown) + .waitForElementPresent(linkFailureDropdown) + .waitForElementPresent(linkAlwaysDropdown) + .findThenClick(linkSuccessDropdown) + .click(linkSelectButton); + }, + 'verify that a new sibling node can be any edge type': client => { + client + .useXpath() + .moveToElement(xPathNodeById(initialJobNodeId), 0, 0, () => { client.pause(500); client.waitForElementNotVisible(spinny); // Concatenating the xpaths lets us click the proper node - client.click(childNode + nodeAdd); + client.click(xPathNodeById(initialJobNodeId) + nodeAdd); }) .pause(1000) - .waitForElementNotVisible(spinny) + .waitForElementNotVisible(spinny); + + // Grab the id of the new child node for later + client.getAttribute('//*[contains(@class, "WorkflowChart-isNodeBeingAdded")]/..', 'id', (res) => { + newChildNodeId = res.value.split('-')[1]; + }); + + client .clearValue(jobSearchBar) .setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER]) .pause(1000) @@ -115,50 +145,47 @@ module.exports = { .waitForElementPresent(failureDropdown) .waitForElementPresent(alwaysDropdown) .findThenClick(alwaysDropdown) - .click(selectButton); + .click(nodeSelectButton); }, 'Verify node-shifting behavior upon deletion': client => { client - .findThenClick(newChildNode) - .pause(1000) - .waitForElementNotVisible(spinny) - .findThenClick(edgeTypeDropdownBar) - .findThenClick(successDropdown) - .click(selectButton) - .moveToElement(newChildNode, 0, 0, () => { + .moveToElement(xPathNodeById(newChildNodeId), 0, 0, () => { client.pause(500); client.waitForElementNotVisible(spinny); - client.click(newChildNode + nodeAdd); + client.click(xPathNodeById(newChildNodeId) + nodeAdd); }) .pause(1000) - .waitForElementNotVisible(spinny) - .clearValue(jobSearchBar) - .setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER]) - .pause(1000) - .findThenClick(testActionsJob) - .pause(1000) - .waitForElementNotVisible(spinny) - .findThenClick(edgeTypeDropdownBar) - .waitForElementPresent(successDropdown) - .waitForElementPresent(failureDropdown) - .waitForElementPresent(alwaysDropdown) - .findThenClick(alwaysDropdown) - .click(selectButton) - .moveToElement(newChildNode, 0, 0, () => { - client.pause(500); - client.waitForElementNotVisible(spinny); - client.click(newChildNode + nodeRemove); - }) - .pause(1000) - .waitForElementNotVisible(spinny) - .findThenClick(deleteConfirmation) - .findThenClick(leafNode) - .pause(1000) - .waitForElementNotVisible(spinny) - .findThenClick(edgeTypeDropdownBar) - .waitForElementPresent(successDropdown) - .waitForElementPresent(failureDropdown) - .waitForElementPresent(alwaysDropdown); + .waitForElementNotVisible(spinny); + + // Grab the id of the new child node for later + client.getAttribute('//*[contains(@class, "WorkflowChart-isNodeBeingAdded")]/..', 'id', (res) => { + // I had to nest this logic in order to ensure that leafNodeId was available later on. + // Separating this out resulted in leafNodeId being `undefined` when sent to + // xPathLinkById + leafNodeId = res.value.split('-')[1]; + client + .clearValue(jobSearchBar) + .setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER]) + .pause(1000) + .findThenClick(testActionsJob) + .pause(1000) + .waitForElementNotVisible(spinny) + .findThenClick(edgeTypeDropdownBar) + .waitForElementPresent(successDropdown) + .waitForElementPresent(failureDropdown) + .waitForElementPresent(alwaysDropdown) + .findThenClick(alwaysDropdown) + .click(nodeSelectButton) + .moveToElement(xPathNodeById(newChildNodeId), 0, 0, () => { + client.pause(500); + client.waitForElementNotVisible(spinny); + client.click(xPathNodeById(newChildNodeId) + nodeRemove); + }) + .pause(1000) + .waitForElementNotVisible(spinny) + .findThenClick(deleteConfirmation) + .waitForElementVisible(xPathLinkById(initialJobNodeId, leafNodeId)); + }); }, after: client => { client.end(); 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(); - }); - - }); - -}); diff --git a/docs/img/workflow_step0.png b/docs/img/workflow_step0.png new file mode 100644 index 0000000000..25495c139a Binary files /dev/null and b/docs/img/workflow_step0.png differ diff --git a/docs/img/workflow_step1.png b/docs/img/workflow_step1.png new file mode 100644 index 0000000000..984e31b528 Binary files /dev/null and b/docs/img/workflow_step1.png differ diff --git a/docs/img/workflow_step2.png b/docs/img/workflow_step2.png new file mode 100644 index 0000000000..d9d9c70606 Binary files /dev/null and b/docs/img/workflow_step2.png differ diff --git a/docs/img/workflow_step3.png b/docs/img/workflow_step3.png new file mode 100644 index 0000000000..56cc7c5445 Binary files /dev/null and b/docs/img/workflow_step3.png differ diff --git a/docs/img/workflow_step4.png b/docs/img/workflow_step4.png new file mode 100644 index 0000000000..1cf722d2f3 Binary files /dev/null and b/docs/img/workflow_step4.png differ 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. diff --git a/docs/workflow.md b/docs/workflow.md index f27ebce5eb..e4becca74f 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -54,12 +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. -* 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. +### 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. @@ -68,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 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. @@ -84,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. @@ -98,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. @@ -113,7 +134,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.