mirror of
https://github.com/ansible/awx.git
synced 2026-01-17 04:31:21 -03:30
Merge pull request #2389 from ansible/workflow-convergence
Workflow convergence Reviewed-by: https://github.com/softwarefactory-project-zuul[bot]
This commit is contained in:
commit
a9c51b737c
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
20
awx/main/migrations/0054_v340_workflow_convergence.py
Normal file
20
awx/main/migrations/0054_v340_workflow_convergence.py
Normal 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.'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
42
awx/main/tests/unit/scheduler/test_dag_simple.py
Normal file
42
awx/main/tests/unit/scheduler/test_dag_simple.py
Normal 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
|
||||
318
awx/main/tests/unit/scheduler/test_dag_workflow.py
Normal file
318
awx/main/tests/unit/scheduler/test_dag_workflow.py
Normal 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"))
|
||||
@ -34,7 +34,8 @@
|
||||
"describe": false,
|
||||
"moment": false,
|
||||
"spyOn": false,
|
||||
"jasmine": false
|
||||
"jasmine": false,
|
||||
"dagre": false
|
||||
},
|
||||
"strict": false,
|
||||
"quotmark": false,
|
||||
|
||||
@ -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'];
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
}];
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
];
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
];
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
];
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
];
|
||||
@ -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') }} </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') }} </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>
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
},
|
||||
};
|
||||
}];
|
||||
@ -38,6 +38,7 @@ require('moment');
|
||||
require('rrule');
|
||||
require('sprintf-js');
|
||||
require('reconnectingwebsocket');
|
||||
global.dagre = require('dagre');
|
||||
|
||||
// D3 + extensions
|
||||
require('d3');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
17
awx/ui/package-lock.json
generated
17
awx/ui/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
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
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
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
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
BIN
docs/img/workflow_step4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
19
docs/licenses/ui/dagre.txt
Normal file
19
docs/licenses/ui/dagre.txt
Normal 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.
|
||||
19
docs/licenses/ui/graphlib.txt
Normal file
19
docs/licenses/ui/graphlib.txt
Normal 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.
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user