Merge pull request #2389 from ansible/workflow-convergence

Workflow convergence

Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
softwarefactory-project-zuul[bot] 2018-11-27 22:04:50 +00:00 committed by GitHub
commit a9c51b737c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 4770 additions and 3139 deletions

View File

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

View File

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

View File

@ -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.'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,7 +34,8 @@
"describe": false,
"moment": false,
"spyOn": false,
"jasmine": false
"jasmine": false,
"dagre": false
},
"strict": false,
"quotmark": false,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
<div class="WorkflowMaker-formTitle">{{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 : ''}}</div>
<div class="WorkflowMaker-form">
<div class="form-group Form-formGroup Form-formGroup--singleColumn">
<div ng-show="linkConfig.mode === 'add' && !linkConfig.target">{{:: strings.get('workflow_maker.NEW_LINK')}}</div>
<span ng-show="linkConfig.target">
<label for="edgeType" class="Form-inputLabelContainer">
<span class="Form-requiredAsterisk">*</span>
<span class="Form-inputLabel">{{:: strings.get('workflow_maker.RUN') }}</span>
</label>
<div>
<select
id="workflow_link_edge"
ng-options="v as v.label for v in edgeTypeOptions track by v.value"
ng-model="edgeType"
class="form-control Form-dropDown"
name="edgeType"
tabindex="-1"
ng-disabled="readOnly"
aria-hidden="true">
</select>
</div>
</span>
</div>
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_unlink_btn" ng-show="!readOnly && linkConfig.canUnlink" ng-click="unlink()"> {{:: strings.get('workflow_maker.UNLINK') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_link_btn" ng-show="!readOnly" ng-click="cancel()"> {{:: strings.get('CANCEL') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_link_btn" ng-show="readOnly" ng-click="cancel()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_link_btn" ng-show="!readOnly && linkConfig.target" ng-click="select({edgeType: edgeType.value})" ng-disabled="!edgeType"> {{:: strings.get('SAVE') }}</button>
</div>
</div>

View File

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

View File

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

View File

@ -0,0 +1,245 @@
<div ng-show="nodeFormDataLoaded">
<div class="WorkflowMaker-formTitle">{{nodeConfig.mode === 'edit' ? nodeConfig.node.fullUnifiedJobTemplateObject.name || nodeConfig.node.unifiedJobTemplate.name : strings.get('workflow_maker.ADD_A_TEMPLATE')}}</div>
<div class="Form-tabHolder" ng-show="!readOnly">
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'jobs'}" ng-click="activeTab = 'jobs'">{{strings.get('workflow_maker.JOBS')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'project_syncs'}" ng-click="activeTab = 'project_syncs'">{{strings.get('workflow_maker.PROJECT_SYNC')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': activeTab === 'inventory_syncs'}" ng-click="activeTab = 'inventory_syncs'">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</div>
</div>
<div class="WorkflowMaker-formLists" ng-show="!readOnly">
<div id="workflow-jobs-list" ng-show="activeTab === 'jobs'">
<div ng-hide="wf_maker_templates.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_templates" base-path="unified_job_templates" iterator="wf_maker_template" dataset="wf_maker_template_dataset" list="templateList" collection="wf_maker_templates" default-params="wf_maker_template_default_params" query-set="wf_maker_template_queryset" search-bar-full-width="true" search-tags="searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_templates.length === 0 && !(searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_templates.length === 0 && (searchTags | isEmpty)">PLEASE ADD ITEMS TO THIS LIST</div>
<div class="list-table-container" ng-show="wf_maker_templates.length > 0">
<table id="templates_table" class="List-table" is-extended="false">
<thead>
<tr class="List-tableHeaderRow">
<th class="List-tableHeader select-column List-staticColumn--smallStatus" translate=""></th>
<th class="col-md-8" base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" column-sort="" column-field="name" column-iterator="wf_maker_template" column-no-sort="undefined" column-label="Name" column-custom-class="" query-set="wf_maker_template_queryset">
</th>
<th class="List-tableHeader--info col-md-3" base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" column-sort="" column-field="info" column-iterator="wf_maker_template" column-no-sort="true" column-label="" column-custom-class="" query-set="wf_maker_template_queryset">
</th>
</tr>
</thead>
<tbody>
<tr ng-class="[template.success_class, {'List-tableRow--selected' : $stateParams['template_id'] == wf_maker_template.id}, {'List-tableRow--disabled': !wf_maker_template.summary_fields.user_capabilities.edit}]" id="{{ wf_maker_template.id }}" class="List-tableRow template_class" disable-row="{{ !wf_maker_template.summary_fields.user_capabilities.edit }}" ng-repeat="wf_maker_template in wf_maker_templates">
<td class="List-tableCell">
<input type="radio" ng-model="wf_maker_template.checked" ng-value="1" ng-false-value="0" name="check_template_{{wf_maker_template.id}}" ng-click="toggle_row(wf_maker_template)" ng-disabled="!wf_maker_template.summary_fields.user_capabilities.edit">
</td>
<td class="List-tableCell name-column col-md-8" ng-click="toggle_row(wf_maker_template)">
{{wf_maker_template.name}}
<span class="at-RowItem-tag" ng-show="wf_maker_template.type === 'workflow_job_template'">
{{:: strings.get('workflow_maker.WORKFLOW') }}
</span>
</td>
<td class="col-md-3" ng-include="'/static/partials/job-template-details.html'"></td>
</tr>
</tbody>
</table>
</div>
<paginate base-path="unified_job_templates" collection="wf_maker_templates" dataset="wf_maker_template_dataset" iterator="wf_maker_template" query-set="wf_maker_template_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
<div id="workflow-project-sync-list" ng-show="activeTab === 'project_syncs'">
<div ng-hide="wf_maker_projects.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_projects" base-path="projects" iterator="wf_maker_project" dataset="wf_maker_project_dataset" list="projectList" collection="wf_maker_projects" default-params="wf_maker_project_default_params" query-set="wf_maker_project_queryset" search-bar-full-width="true" search-tags="searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_projects.length === 0 && !(searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_projects.length === 0 && (searchTags | isEmpty)">No Projects Have Been Created</div>
<div class="list-table-container" ng-show="wf_maker_projects.length > 0">
<table id="projects_table" class="List-table" is-extended="false">
<thead>
<tr class="List-tableHeaderRow">
<th class="List-tableHeader select-column List-staticColumn--smallStatus" translate=""></th>
<th base-path="projects" collection="wf_maker_projects" dataset="wf_maker_project_dataset" column-sort="" column-field="name" column-iterator="wf_maker_project" column-no-sort="undefined" column-label="Name" column-custom-class="col-md-8" query-set="wf_maker_project_queryset">
</th>
</tr>
</thead>
<tbody>
<tr ng-class="[wf_maker_project.success_class, {'List-tableRow--selected' : $stateParams['project_id'] == wf_maker_project.id}]" id="{{ wf_maker_project.id }}" class="List-tableRow project_class" ng-repeat="wf_maker_project in wf_maker_projects">
<td class="List-tableCell">
<input type="radio" ng-model="wf_maker_project.checked" ng-value="1" ng-false-value="0" name="check_project_{{wf_maker_project.id}}" ng-click="toggle_row(wf_maker_project)" ng-disabled="undefined">
</td>
<td class="List-tableCell name-column col-md-8" ng-click="toggle_row(wf_maker_project)">
{{ wf_maker_project.name }}</td>
</tr>
</tbody>
</table>
</div>
<paginate base-path="projects" collection="wf_maker_projects" dataset="wf_maker_project_dataset" iterator="wf_maker_project" query-set="wf_maker_project_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
<div id="workflow-inventory-sync-list" ng-show="activeTab === 'inventory_syncs'">
<div ng-hide="wf_maker_inventory_sources.length === 0 && (searchTags | isEmpty)">
<smart-search django-model="wf_maker_inventory_sources" base-path="inventory_sources" iterator="wf_maker_inventory_source" dataset="wf_maker_inventory_source_dataset" list="inventorySourceList" collection="wf_maker_inventory_sources" default-params="wf_maker_inventory_source_default_params" query-set="wf_maker_inventory_source_queryset" search-bar-full-width="true" search-tags="searchTags">
</smart-search>
</div>
<div class="row" ng-show="wf_maker_inventory_sources.length === 0 && !(searchTags | isEmpty)">
<div class="col-lg-12 List-searchNoResults" translate>No records matched your search.</div>
</div>
<div class="List-noItems" ng-show="wf_maker_inventory_sources.length === 0 && (searchTags | isEmpty)">PLEASE ADD ITEMS TO THIS LIST</div>
<div class="list-table-container" ng-show="wf_maker_inventory_sources.length > 0">
<table id="workflow_inventory_sources_table" class="List-table" is-extended="false">
<thead>
<tr class="List-tableHeaderRow">
<th class="List-tableHeader select-column List-staticColumn--smallStatus" translate=""></th>
<th base-path="inventory_sources" collection="wf_maker_inventory_sources" dataset="wf_maker_inventory_source_dataset" column-sort="" column-field="name" column-iterator="wf_maker_inventory_source" column-no-sort="undefined" column-label="Name" column-custom-class="" query-set="wf_maker_inventory_source_queryset">
</th>
</tr>
</thead>
<tbody>
<tr ng-class="[wf_maker_inventory_source.success_class, {'List-tableRow--selected' : $stateParams['inventory_source_id'] == wf_maker_inventory_source.id}]" id="{{ wf_maker_inventory_source.id }}" class="List-tableRow inventory_source_class" ng-repeat="wf_maker_inventory_source in wf_maker_inventory_sources">
<td class="List-tableCell">
<input type="radio" ng-model="wf_maker_inventory_source.checked" ng-value="1" ng-false-value="0" name="check_inventory_source_{{wf_maker_inventory_source.id}}" ng-click="toggle_row(wf_maker_inventory_source)" ng-disabled="undefined">
</td>
<td class="List-tableCell name-column col-md-11" ng-click="toggle_row(wf_maker_inventory_source)">
<span aw-tool-tip="Inventory: {{wf_maker_inventory_source.summary_fields.inventory.name}}" data-placement="top">{{ wf_maker_inventory_source.name }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<paginate base-path="inventory_sources" collection="wf_maker_inventory_sources" dataset="wf_maker_inventory_source_dataset" iterator="wf_maker_inventory_source" query-set="wf_maker_inventory_source_queryset" hide-view-per-page="true" max-visible-pages="5"></paginate>
</div>
</div>
<div ng-if="selectedTemplate && selectedTemplateInvalid">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}</span>
</div>
</div>
<div ng-if="selectedTemplate && credentialRequiresPassword">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}</span>
</div>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn" ng-show="nodeConfig.mode === 'add'">
<label for="edgeType" class="Form-inputLabelContainer">
<span class="Form-requiredAsterisk">*</span>
<span class="Form-inputLabel">{{:: strings.get('workflow_maker.RUN') }}</span>
</label>
<div>
<select
id="workflow_node_edge"
ng-options="v as v.label for v in edgeTypeOptions track by v.value"
ng-model="edgeType"
class="form-control Form-dropDown"
name="edgeType"
tabindex="-1"
ng-disabled="readOnly"
aria-hidden="true">
</select>
</div>
</div>
<div ng-show="readOnly">
<div
class="WorkflowMaker-readOnlyPromptText"
ng-show="nodeConfig.node.originalNodeObject.job_type !== null ||
(promptData.prompts.credentials.value && promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
showExtraVars">
{{:: strings.get('workflow_maker.READ_ONLY_PROMPT_VALUES')}}
</div>
<div
class="WorkflowMaker-readOnlyPromptText"
ng-show="!(nodeConfig.node.originalNodeObject.job_type !== null ||
(promptData.prompts.credentials.value && promptData.prompts.credentials.value.length > 0) ||
nodeConfig.node.originalNodeObject.inventory !== null ||
nodeConfig.node.originalNodeObject.limit !== null ||
nodeConfig.node.originalNodeObject.verbosity !== null ||
nodeConfig.node.originalNodeObject.job_tags !== null ||
nodeConfig.node.originalNodeObject.skip_tags !== null ||
nodeConfig.node.originalNodeObject.diff_mode !== null ||
showExtraVars)">
{{:: strings.get('workflow_maker.READ_ONLY_NO_PROMPT_VALUES')}}
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.job_type !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.JOB_TYPE') }}</div>
<div class="Prompt-previewRowValue">
<span ng-if="nodeConfig.node.originalNodeObject.job_type === 'run'">{{:: strings.get('prompt.PLAYBOOK_RUN') }}</span>
<span ng-if="nodeConfig.node.originalNodeObject.job_type === 'check'">{{:: strings.get('prompt.CHECK') }}</span>
</div>
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.inventory !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.INVENTORY') }}</div>
<div class="Prompt-previewRowValue" ng-bind="nodeConfig.node.originalNodeObject.summary_fields.inventory.name"></div>
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.limit !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.LIMIT') }}</div>
<div class="Prompt-previewRowValue" ng-bind="nodeConfig.node.originalNodeObject.limit"></div>
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.verbosity !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.VERBOSITY') }}</div>
<div class="Prompt-previewRowValue" ng-bind="nodeConfig.node.originalNodeObject.verbosity"></div>
</div>
<div class="Prompt-previewRow--noflex" ng-if="nodeConfig.node.originalNodeObject.job_tags !== null">
<div class="Prompt-previewRowTitle">
<span>{{:: strings.get('prompt.JOB_TAGS') }}&nbsp;</span>
<span ng-click="showJobTags = !showJobTags">
<span class="fa fa-caret-down" ng-show="showJobTags" ></span>
<span class="fa fa-caret-left" ng-show="!showJobTags"></span>
</span>
</div>
<div ng-show="showJobTags" class="Prompt-previewTagContainer">
<div class="u-wordwrap" ng-repeat="tag in jobTags">
<div class="LabelList-tag">
<span>{{tag}}</span>
</div>
</div>
</div>
</div>
<div class="Prompt-previewRow--noflex" ng-if="nodeConfig.node.originalNodeObject.skip_tags !== null">
<div class="Prompt-previewRowTitle">
<span>{{:: strings.get('prompt.SKIP_TAGS') }}&nbsp;</span>
<span ng-click="showSkipTags = !showSkipTags">
<span class="fa fa-caret-down" ng-show="showSkipTags" ></span>
<span class="fa fa-caret-left" ng-show="!showSkipTags"></span>
</span>
</div>
<div ng-show="showSkipTags" class="Prompt-previewTagContainer">
<div class="u-wordwrap" ng-repeat="tag in skipTags">
<div class="LabelList-tag">
<span>{{tag}}</span>
</div>
</div>
</div>
</div>
<div class="Prompt-previewRow--flex" ng-if="nodeConfig.node.originalNodeObject.diff_mode !== null">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.SHOW_CHANGES') }}</div>
<div class="Prompt-previewRowValue">
<span ng-if="promptData.prompts.diffMode.value">{{:: strings.get('ON') }}</span>
<span ng-if="!promptData.prompts.diffMode.value">{{:: strings.get('OFF') }}</span>
</div>
</div>
<div class="Prompt-previewRow--noflex" ng-show="showExtraVars">
<div class="Prompt-previewRowTitle">{{:: strings.get('prompt.EXTRA_VARIABLES') }}</div>
<div>
<textarea rows="6" ng-model="extraVars" name="node_form_extra_vars" class="form-control Form-textArea Form-textAreaLabel" id="workflow_node_form_extra_vars"></textarea>
</div>
</div>
</div>
<div ng-show="editNodeHelpMessage && activeTab === 'jobs'" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<br />
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton && activeTab == 'jobs' " ng-click="openPromptModal()"> {{:: strings.get('prompt.PROMPT') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_node_btn" ng-show="!readOnly" ng-click="cancel()"> {{:: strings.get('CANCEL') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_close_node_btn" ng-show="readOnly" ng-click="cancel()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_node_btn" ng-show="!readOnly" ng-click="select({selectedTemplate, promptData, edgeType})" ng-disabled="!selectedTemplate || promptModalMissingReqFields || credentialRequiresPassword || selectedTemplateInvalid"> {{:: strings.get('workflow_maker.SELECT') }}</button>
</div>
<prompt prompt-data="promptData" action-text="{{:: strings.get('prompt.CONFIRM')}}" prevent-creds-with-passwords="preventCredsWithPasswords" read-only-prompts="readOnly"></prompt>
</div>

View File

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

View File

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

View File

@ -28,7 +28,7 @@
<div class="WorkflowMaker-titleText">{{strings.get('workflow_maker.TITLE')}} | {{ workflowJobTemplateObj.name }}</div>
</div>
<div class="WorkflowMaker-exitHolder">
<button class="WorkflowMaker-exit" ng-click="closeWorkflowMaker()">
<button class="WorkflowMaker-exit" ng-click="closeDialog()">
<i class="fa fa-times-circle"></i>
</button>
</div>
@ -73,81 +73,40 @@
</ul>
</div>
<div class="WorkflowLegend-maker--right">
<span class="WorkflowMaker-totalJobs">{{strings.get('workflow_maker.TOTAL_TEMPLATES')}}</span>
<span class="badge List-titleBadge" ng-bind="treeData.data.totalNodes"></span>
<span class="WorkflowMaker-totalJobs">{{strings.get('workflow_maker.TOTAL_NODES')}}</span>
<span class="badge List-titleBadge" ng-bind="graphState.arrayOfNodesForChart.length === 0 ? graphState.arrayOfNodesForChart.length : graphState.arrayOfNodesForChart.length-1"></span>
<i ng-class="{'WorkflowMaker-manualControlsIcon--active': showManualControls}" class="fa fa-cog WorkflowMaker-manualControlsIcon" aria-hidden="true" alt="Controls" ng-click="toggleManualControls()"></i>
<div ng-show="showManualControls" class="WorkflowMaker-manualControls noselect">
<workflow-controls class="WorkflowControls" pan-chart="panChart(direction)" zoom-chart="zoomChart(zoom)" reset-chart="resetChart()" zoom-to-fit-chart="zoomToFitChart()"></workflow-controls>
</div>
</div>
</div>
<workflow-chart ng-if="modalOpen" tree-data="treeData.data" add-node="startAddNode(parent, betweenTwoNodes)" edit-node="startEditNode(nodeToEdit)" delete-node="startDeleteNode(nodeToDelete)" workflow-zoomed="workflowZoomed(zoom)" can-add-workflow-job-template="canAddWorkflowJobTemplate" workflow-job-template-obj="workflowJobTemplateObj" mode="edit" class="WorkflowMaker-chart"></workflow-chart>
<workflow-chart
ng-if="modalOpen"
graph-state="graphState"
add-node-without-child="startAddNodeWithoutChild(parent)"
add-node-with-child="startAddNodeWithChild(link)"
edit-node="startEditNode(nodeToEdit)"
edit-link="startEditLink(linkToEdit)"
select-node-for-linking="selectNodeForLinking(nodeToStartLink)"
delete-node="startDeleteNode(nodeToDelete)"
workflow-zoomed="workflowZoomed(zoom)"
read-only="readOnly"
mode="edit"
class="WorkflowMaker-chart">
</workflow-chart>
</div>
<div class="WorkflowMaker-contentRight">
<div class="WorkflowMaker-formTitle">{{(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')}}</div>
<div class="WorkflowMaker-formHelp" ng-show="workflowMakerFormConfig.nodeMode === 'idle'" ng-bind="treeData.data.totalNodes === 0 ? strings.get('workflow_maker.PLEASE_CLICK_THE_START_BUTTON') : strings.get('workflow_maker.PLEASE_HOVER_OVER_A_TEMPLATE')"></div>
<div class="WorkflowMaker-form" ng-show="workflowMakerFormConfig.nodeMode === 'add' || workflowMakerFormConfig.nodeMode === 'edit'">
<div class="Form-tabHolder">
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': workflowMakerFormConfig.activeTab === 'jobs'}" ng-click="toggleFormTab('jobs')">{{strings.get('workflow_maker.JOBS')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': workflowMakerFormConfig.activeTab === 'project_sync'}" ng-click="toggleFormTab('project_sync')">{{strings.get('workflow_maker.PROJECT_SYNC')}}</div>
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': workflowMakerFormConfig.activeTab === 'inventory_sync'}" ng-click="toggleFormTab('inventory_sync')">{{strings.get('workflow_maker.INVENTORY_SYNC')}}</div>
</div>
<div class="WorkflowMaker-formLists">
<div id="workflow-jobs-list" ui-view="jobTemplateList" ng-show="workflowMakerFormConfig.activeTab === 'jobs'"></div>
<div id="workflow-project-sync-list" ui-view="projectSyncList" ng-show="workflowMakerFormConfig.activeTab === 'project_sync'"></div>
<div id="workflow-inventory-sync-list" ui-view="inventorySyncList" ng-show="workflowMakerFormConfig.activeTab === 'inventory_sync'"></div>
</div>
<span ng-show="selectedTemplate &&
((selectedTemplate.type === 'job_template' || selectedTemplate.type === 'workflow_job_template' && workflowMakerFormConfig.activeTab === 'jobs') ||
(selectedTemplate.unified_job_type === 'job' || selectedTemplate.unified_job_type === 'workflow_job' && workflowMakerFormConfig.activeTab === 'jobs') ||
(selectedTemplate.type === 'project' && workflowMakerFormConfig.activeTab === 'project_sync') ||
(selectedTemplate.unified_job_type === 'inventory_update' && workflowMakerFormConfig.activeTab === 'inventory_sync') ||
(selectedTemplate.type === 'inventory_source' && workflowMakerFormConfig.activeTab === 'inventory_sync'))">
<div ng-if="selectedTemplate && selectedTemplateInvalid">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}</span>
</div>
</div>
<div ng-if="selectedTemplate && credentialRequiresPassword">
<div class="WorkflowMaker-invalidJobTemplateWarning">
<span class="fa fa-warning"></span>
<span>{{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }}</span>
</div>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn" ng-show="selectedTemplate && !selectedTemplateInvalid && !(credentialRequiresPassword && !promptData.launchConf.ask_credential_on_launch)">
<label for="verbosity" class="Form-inputLabelContainer">
<span class="Form-requiredAsterisk">*</span>
<span class="Form-inputLabel">{{:: strings.get('workflow_maker.RUN') }}</span>
</label>
<div>
<select
id="workflow_node_edge"
ng-options="v as v.label for v in edgeTypeOptions track by v.value"
ng-model="edgeType"
class="form-control Form-dropDown"
name="edgeType"
tabindex="-1"
ng-disabled="!workflowJobTemplateObj.summary_fields.user_capabilities.edit"
aria-hidden="true">
</select>
</div>
</div>
<div ng-show="editNodeHelpMessage" class="WorkflowMaker-formHelp" ng-bind="editNodeHelpMessage"></div>
<br />
<div class="buttons Form-buttons" id="workflow_maker_controls">
<button type="button" class="btn btn-sm Form-primaryButton Form-primaryButton--noMargin" id="workflow_maker_prompt_btn" ng-show="showPromptButton" ng-click="openPromptModal()"> {{:: strings.get('prompt.PROMPT') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_cancel_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> {{:: strings.get('CANCEL') }}</button>
<button type="button" class="btn btn-sm Form-cancelButton" id="workflow_maker_close_btn" ng-show="!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)" ng-click="cancelNodeForm()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm Form-saveButton" id="workflow_maker_select_btn" ng-show="(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) && !selectedTemplateInvalid && !(credentialRequiresPassword && !promptData.launchConf.ask_credential_on_launch)" ng-click="confirmNodeForm()" ng-disabled="!selectedTemplate || promptModalMissingReqFields || credentialRequiresPassword"> {{:: strings.get('workflow_maker.SELECT') }}</button>
</div>
</span>
</div>
<span ng-if="formState.showNodeForm">
<workflow-node-form node-config="nodeConfig" workflow-job-template-obj="workflowJobTemplateObj" select="confirmNodeForm(selectedTemplate, promptData, edgeType)" cancel="cancelNodeForm()" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit"/>
</span>
<span ng-if="formState.showLinkForm">
<workflow-link-form link-config="linkConfig" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit" select="confirmLinkForm(edgeType)" cancel="cancelLinkForm()" unlink="unlink()"/>
</span>
</div>
</div>
<div class="WorkflowMaker-buttonHolder">
<button type="button" class="btn btn-sm WorkflowMaker-cancelButton" ng-click="closeWorkflowMaker()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm WorkflowMaker-saveButton" ng-click="saveWorkflowMaker()" ng-show="workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate" ng-disabled="workflowMakerFormConfig.nodeMode === 'add'"> {{:: strings.get('SAVE') }}</button>
<button type="button" class="btn btn-sm WorkflowMaker-cancelButton" ng-click="closeDialog()"> {{:: strings.get('CLOSE') }}</button>
<button type="button" class="btn btn-sm WorkflowMaker-saveButton" ng-click="saveWorkflowMaker()" ng-show="workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate" ng-disabled="formState.showNodeForm || formState.showLinkForm"> {{:: strings.get('SAVE') }}</button>
</div>
<prompt prompt-data="promptData" action-text="{{:: strings.get('prompt.CONFIRM')}}" prevent-creds-with-passwords="preventCredsWithPasswords" read-only-prompts="!(workflowJobTemplateObj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)"></prompt>
</div>

View File

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

View File

@ -38,6 +38,7 @@ require('moment');
require('rrule');
require('sprintf-js');
require('reconnectingwebsocket');
global.dagre = require('dagre');
// D3 + extensions
require('d3');

View File

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

View File

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

View File

@ -291,7 +291,7 @@
<div class="WorkflowResults-badgeRow">
<!-- PLAYS COUNT -->
<div class="WorkflowResults-badgeTitle">
{{ strings.results.TOTAL_JOBS }}
{{ strings.results.TOTAL_NODES }}
</div>
<span class="badge List-titleBadge">
{{ workflow_nodes.length || 0}}
@ -363,7 +363,14 @@
</div>
</div>
</div>
<workflow-chart tree-data="treeData.data" workflow-zoomed="workflowZoomed(zoom)" can-add-workflow-job-template="canAddWorkflowJobTemplate" mode="details" class="WorkflowMaker-chart"></workflow-chart>
<workflow-chart
graph-state="graphState"
workflow-zoomed="workflowZoomed(zoom)"
can-add-workflow-job-template="canAddWorkflowJobTemplate"
mode="details"
read-only="readOnly"
class="WorkflowMaker-chart">
</workflow-chart>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
docs/img/workflow_step0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
docs/img/workflow_step1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/img/workflow_step2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/img/workflow_step3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/img/workflow_step4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

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

View File

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

View File

@ -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 `<do_not_run, job_status, node_id>` 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.
<p align="center">
<img src="img/workflow_step0.png">
Workflow before running has started.
</p>
<p align="center">
<img src="img/workflow_step1.png">
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'.
</p>
<p align="center">
<img src="img/workflow_step2.png">
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.
</p>
<p align="center">
<img src="img/workflow_step3.png">
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.
</p>
<p align="center">
<img src="img/workflow_step4.png">
Node 7 and 8 are selected to run and their job results are both 'successful'.
</p>
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.