mirror of
https://github.com/ansible/awx.git
synced 2026-05-08 01:47:35 -02: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:
@@ -3916,7 +3916,8 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = WorkflowJobNode
|
model = WorkflowJobNode
|
||||||
fields = ('*', 'credential', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related',
|
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):
|
def get_related(self, obj):
|
||||||
res = super(WorkflowJobNodeSerializer, self).get_related(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.serializers import * # noqa
|
||||||
from awx.api.metadata import RoleMetadata, JobTypeMetadata
|
from awx.api.metadata import RoleMetadata, JobTypeMetadata
|
||||||
from awx.main.constants import ACTIVE_STATES
|
from awx.main.constants import ACTIVE_STATES
|
||||||
|
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
||||||
from awx.api.views.mixin import (
|
from awx.api.views.mixin import (
|
||||||
ActivityStreamEnforcementMixin,
|
ActivityStreamEnforcementMixin,
|
||||||
SystemTrackingEnforcementMixin,
|
SystemTrackingEnforcementMixin,
|
||||||
@@ -143,6 +144,9 @@ from awx.api.views.root import ( # noqa
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger('awx.api.views')
|
||||||
|
|
||||||
|
|
||||||
def api_exception_handler(exc, context):
|
def api_exception_handler(exc, context):
|
||||||
'''
|
'''
|
||||||
Override default API exception handler to catch IntegrityError exceptions.
|
Override default API exception handler to catch IntegrityError exceptions.
|
||||||
@@ -2950,35 +2954,17 @@ class WorkflowJobTemplateNodeChildrenBaseList(WorkflowsEnforcementMixin, Enforce
|
|||||||
if created:
|
if created:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
workflow_nodes = parent.workflow_job_template.workflow_job_template_nodes.all().\
|
if parent.id == sub.id:
|
||||||
prefetch_related('success_nodes', 'failure_nodes', 'always_nodes')
|
return {"Error": _("Cycle detected.")}
|
||||||
graph = {}
|
|
||||||
for workflow_node in workflow_nodes:
|
|
||||||
graph[workflow_node.pk] = dict(node_object=workflow_node, metadata={'parent': None, 'traversed': False})
|
|
||||||
|
|
||||||
find = False
|
parent_node_type_relationship = getattr(parent, self.relationship)
|
||||||
for node_type in ['success_nodes', 'failure_nodes', 'always_nodes']:
|
parent_node_type_relationship.add(sub)
|
||||||
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']
|
|
||||||
|
|
||||||
|
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
|
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()
|
success_parents = getattr(self, '%ss_success' % self.__class__.__name__.lower()).all()
|
||||||
failure_parents = getattr(self, '%ss_failure' % 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()
|
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
|
@classmethod
|
||||||
def _get_workflow_job_field_names(cls):
|
def _get_workflow_job_field_names(cls):
|
||||||
@@ -184,6 +184,12 @@ class WorkflowJobNode(WorkflowNodeBase):
|
|||||||
default={},
|
default={},
|
||||||
editable=False,
|
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):
|
def get_absolute_url(self, request=None):
|
||||||
return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request)
|
return reverse('api:workflow_job_node_detail', kwargs={'pk': self.pk}, request=request)
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
|
from collections import deque
|
||||||
from awx.main.models import (
|
|
||||||
Job,
|
|
||||||
AdHocCommand,
|
|
||||||
InventoryUpdate,
|
|
||||||
ProjectUpdate,
|
|
||||||
WorkflowJob,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleDAG(object):
|
class SimpleDAG(object):
|
||||||
@@ -13,12 +6,51 @@ class SimpleDAG(object):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.nodes = []
|
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):
|
def __contains__(self, obj):
|
||||||
for node in self.nodes:
|
if self.node['node_object'] in self.node_obj_to_node_index:
|
||||||
if node['node_object'] == obj:
|
return True
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
@@ -27,98 +59,169 @@ class SimpleDAG(object):
|
|||||||
def __iter__(self):
|
def __iter__(self):
|
||||||
return self.nodes.__iter__()
|
return self.nodes.__iter__()
|
||||||
|
|
||||||
def generate_graphviz_plot(self):
|
def generate_graphviz_plot(self, file_name="/awx_devel/graph.gv"):
|
||||||
def short_string_obj(obj):
|
def run_status(obj):
|
||||||
if type(obj) == Job:
|
dnr = "RUN"
|
||||||
type_str = "Job"
|
status = "NA"
|
||||||
if type(obj) == AdHocCommand:
|
if hasattr(obj, 'job') and obj.job and hasattr(obj.job, 'status'):
|
||||||
type_str = "AdHocCommand"
|
status = obj.job.status
|
||||||
elif type(obj) == InventoryUpdate:
|
if hasattr(obj, 'do_not_run') and obj.do_not_run is True:
|
||||||
type_str = "Inventory"
|
dnr = "DNR"
|
||||||
elif type(obj) == ProjectUpdate:
|
return "{}_{}_{}".format(dnr, status, obj.id)
|
||||||
type_str = "Project"
|
|
||||||
elif type(obj) == WorkflowJob:
|
|
||||||
type_str = "Workflow"
|
|
||||||
else:
|
|
||||||
type_str = "Unknown"
|
|
||||||
type_str += "%s" % str(obj.id)
|
|
||||||
return type_str
|
|
||||||
|
|
||||||
doc = """
|
doc = """
|
||||||
digraph g {
|
digraph g {
|
||||||
rankdir = LR
|
rankdir = LR
|
||||||
"""
|
"""
|
||||||
for n in self.nodes:
|
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" % (
|
doc += "%s [color = %s]\n" % (
|
||||||
short_string_obj(n['node_object']),
|
run_status(n['node_object']),
|
||||||
"red" if n['node_object'].status == 'running' else "black",
|
color
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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"
|
doc += "}\n"
|
||||||
gv_file = open('/tmp/graph.gv', 'w')
|
gv_file = open(file_name, 'w')
|
||||||
gv_file.write(doc)
|
gv_file.write(doc)
|
||||||
gv_file.close()
|
gv_file.close()
|
||||||
|
|
||||||
def add_node(self, obj, metadata=None):
|
def add_node(self, obj, metadata=None):
|
||||||
if self.find_ord(obj) is 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)
|
from_obj_ord = self.find_ord(from_obj)
|
||||||
to_obj_ord = self.find_ord(to_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:
|
To node is no longer a root node
|
||||||
self.add_edge(edge_pair[0], edge_pair[1], edge_pair[2])
|
'''
|
||||||
|
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):
|
def find_ord(self, obj):
|
||||||
for idx in range(len(self.nodes)):
|
return self.node_obj_to_node_index.get(obj, None)
|
||||||
if obj == self.nodes[idx]['node_object']:
|
|
||||||
return idx
|
def _get_dependencies_by_label(self, node_index, label):
|
||||||
return None
|
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):
|
def get_dependencies(self, obj, label=None):
|
||||||
antecedents = []
|
|
||||||
this_ord = self.find_ord(obj)
|
this_ord = self.find_ord(obj)
|
||||||
for node, dep, lbl in self.edges:
|
nodes = []
|
||||||
if label:
|
if label:
|
||||||
if node == this_ord and lbl == label:
|
return self._get_dependencies_by_label(this_ord, label)
|
||||||
antecedents.append(self.nodes[dep])
|
else:
|
||||||
else:
|
nodes = []
|
||||||
if node == this_ord:
|
map(lambda l: nodes.extend(self._get_dependencies_by_label(this_ord, l)),
|
||||||
antecedents.append(self.nodes[dep])
|
self.node_from_edges_by_label.keys())
|
||||||
return antecedents
|
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):
|
def get_dependents(self, obj, label=None):
|
||||||
decendents = []
|
|
||||||
this_ord = self.find_ord(obj)
|
this_ord = self.find_ord(obj)
|
||||||
for node, dep, lbl in self.edges:
|
nodes = []
|
||||||
if label:
|
if label:
|
||||||
if dep == this_ord and lbl == label:
|
return self._get_dependents_by_label(this_ord, label)
|
||||||
decendents.append(self.nodes[node])
|
else:
|
||||||
else:
|
nodes = []
|
||||||
if dep == this_ord:
|
map(lambda l: nodes.extend(self._get_dependents_by_label(this_ord, l)),
|
||||||
decendents.append(self.nodes[node])
|
self.node_to_edges_by_label.keys())
|
||||||
return decendents
|
return nodes
|
||||||
|
|
||||||
def get_leaf_nodes(self):
|
|
||||||
leafs = []
|
|
||||||
for n in self.nodes:
|
|
||||||
if len(self.get_dependencies(n['node_object'])) < 1:
|
|
||||||
leafs.append(n)
|
|
||||||
return leafs
|
|
||||||
|
|
||||||
def get_root_nodes(self):
|
def get_root_nodes(self):
|
||||||
roots = []
|
return [self.nodes[index] for index in self.root_nodes]
|
||||||
for n in self.nodes:
|
|
||||||
if len(self.get_dependents(n['node_object'])) < 1:
|
def has_cycle(self):
|
||||||
roots.append(n)
|
node_objs = [node['node_object'] for node in self.get_root_nodes()]
|
||||||
return roots
|
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
|
# AWX
|
||||||
from awx.main.scheduler.dag_simple import SimpleDAG
|
from awx.main.scheduler.dag_simple import SimpleDAG
|
||||||
|
|
||||||
@@ -9,44 +18,84 @@ class WorkflowDAG(SimpleDAG):
|
|||||||
if workflow_job:
|
if workflow_job:
|
||||||
self._init_graph(workflow_job)
|
self._init_graph(workflow_job)
|
||||||
|
|
||||||
def _init_graph(self, workflow_job):
|
def _init_graph(self, workflow_job_or_jt):
|
||||||
node_qs = workflow_job.workflow_job_nodes
|
if hasattr(workflow_job_or_jt, 'workflow_job_template_nodes'):
|
||||||
workflow_nodes = node_qs.prefetch_related('success_nodes', 'failure_nodes', 'always_nodes').all()
|
vals = ['from_workflowjobtemplatenode_id', 'to_workflowjobtemplatenode_id']
|
||||||
for workflow_node in workflow_nodes:
|
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)
|
self.add_node(workflow_node)
|
||||||
|
|
||||||
for node_type in ['success_nodes', 'failure_nodes', 'always_nodes']:
|
for edge in success_nodes:
|
||||||
for workflow_node in workflow_nodes:
|
self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'success_nodes')
|
||||||
related_nodes = getattr(workflow_node, node_type).all()
|
for edge in failure_nodes:
|
||||||
for related_node in related_nodes:
|
self.add_edge(wfn_by_id[edge[0]], wfn_by_id[edge[1]], 'failure_nodes')
|
||||||
self.add_edge(workflow_node, related_node, node_type)
|
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):
|
def bfs_nodes_to_run(self):
|
||||||
root_nodes = self.get_root_nodes()
|
nodes = self.get_root_nodes()
|
||||||
nodes = root_nodes
|
|
||||||
nodes_found = []
|
nodes_found = []
|
||||||
|
node_ids_visited = set()
|
||||||
|
|
||||||
for index, n in enumerate(nodes):
|
for index, n in enumerate(nodes):
|
||||||
obj = n['node_object']
|
obj = n['node_object']
|
||||||
job = obj.job
|
if obj.id in node_ids_visited:
|
||||||
|
|
||||||
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']:
|
|
||||||
continue
|
continue
|
||||||
elif job.status == 'failed':
|
node_ids_visited.add(obj.id)
|
||||||
children_failed = self.get_dependencies(obj, 'failure_nodes')
|
|
||||||
children_always = self.get_dependencies(obj, 'always_nodes')
|
if obj.do_not_run is True:
|
||||||
children_all = children_failed + children_always
|
continue
|
||||||
nodes.extend(children_all)
|
|
||||||
elif job.status == 'successful':
|
if obj.job:
|
||||||
children_success = self.get_dependencies(obj, 'success_nodes')
|
if obj.job.status in ['failed', 'error', 'canceled']:
|
||||||
children_always = self.get_dependencies(obj, 'always_nodes')
|
nodes.extend(self.get_dependencies(obj, 'failure_nodes') +
|
||||||
children_all = children_success + children_always
|
self.get_dependencies(obj, 'always_nodes'))
|
||||||
nodes.extend(children_all)
|
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]
|
return [n['node_object'] for n in nodes_found]
|
||||||
|
|
||||||
def cancel_node_jobs(self):
|
def cancel_node_jobs(self):
|
||||||
@@ -63,40 +112,113 @@ class WorkflowDAG(SimpleDAG):
|
|||||||
return cancel_finished
|
return cancel_finished
|
||||||
|
|
||||||
def is_workflow_done(self):
|
def is_workflow_done(self):
|
||||||
root_nodes = self.get_root_nodes()
|
for node in self.nodes:
|
||||||
nodes = root_nodes
|
obj = node['node_object']
|
||||||
is_failed = False
|
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):
|
def has_workflow_failed(self):
|
||||||
obj = n['node_object']
|
failed_nodes = []
|
||||||
job = obj.job
|
res = False
|
||||||
|
failed_path_nodes_id_status = []
|
||||||
|
failed_unified_job_template_node_ids = []
|
||||||
|
|
||||||
if obj.unified_job_template is None:
|
for node in self.nodes:
|
||||||
is_failed = True
|
obj = node['node_object']
|
||||||
continue
|
if obj.do_not_run is False and obj.unified_job_template is None:
|
||||||
elif not job:
|
failed_nodes.append(node)
|
||||||
return False, False
|
elif obj.job and obj.job.status in ['failed', 'canceled', 'error']:
|
||||||
|
failed_nodes.append(node)
|
||||||
|
|
||||||
children_success = self.get_dependencies(obj, 'success_nodes')
|
for node in failed_nodes:
|
||||||
children_failed = self.get_dependencies(obj, 'failure_nodes')
|
obj = node['node_object']
|
||||||
children_always = self.get_dependencies(obj, 'always_nodes')
|
if (len(self.get_dependencies(obj, 'failure_nodes')) +
|
||||||
if not is_failed and job.status != 'successful':
|
len(self.get_dependencies(obj, 'always_nodes'))) == 0:
|
||||||
children_all = children_success + children_failed + children_always
|
if obj.unified_job_template is None:
|
||||||
for child in children_all:
|
res = True
|
||||||
if child['node_object'].job:
|
failed_unified_job_template_node_ids.append(str(obj.id))
|
||||||
break
|
|
||||||
else:
|
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']:
|
if res is True:
|
||||||
continue
|
s = _("No error handle path for workflow job node(s) [{node_status}] workflow job "
|
||||||
elif job.status == 'failed':
|
"node(s) missing unified job template and error handle path [{no_ufjt}].")
|
||||||
nodes.extend(children_failed + children_always)
|
parms = {
|
||||||
elif job.status == 'successful':
|
'node_status': '',
|
||||||
nodes.extend(children_success + children_always)
|
'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:
|
else:
|
||||||
# Job is about to run or is running. Hold our horses and wait for
|
return False
|
||||||
# the job to finish. We can't proceed down the graph path until we
|
return True
|
||||||
# have the job result.
|
|
||||||
return False, False
|
def mark_dnr_nodes(self):
|
||||||
return True, is_failed
|
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)
|
dag = WorkflowDAG(workflow_job)
|
||||||
status_changed = False
|
status_changed = False
|
||||||
if workflow_job.cancel_flag:
|
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)
|
logger.debug('Canceling spawned jobs of %s due to cancel flag.', workflow_job.log_format)
|
||||||
cancel_finished = dag.cancel_node_jobs()
|
cancel_finished = dag.cancel_node_jobs()
|
||||||
if cancel_finished:
|
if cancel_finished:
|
||||||
@@ -172,16 +173,24 @@ class TaskManager():
|
|||||||
workflow_job.save(update_fields=['status', 'start_args'])
|
workflow_job.save(update_fields=['status', 'start_args'])
|
||||||
status_changed = True
|
status_changed = True
|
||||||
else:
|
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:
|
if not is_done:
|
||||||
continue
|
continue
|
||||||
|
has_failed, reason = dag.has_workflow_failed()
|
||||||
logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful')
|
logger.info('Marking %s as %s.', workflow_job.log_format, 'failed' if has_failed else 'successful')
|
||||||
result.append(workflow_job.id)
|
result.append(workflow_job.id)
|
||||||
new_status = 'failed' if has_failed else 'successful'
|
new_status = 'failed' if has_failed else 'successful'
|
||||||
logger.debug(six.text_type("Transitioning {} to {} status.").format(workflow_job.log_format, new_status))
|
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
|
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.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
|
status_changed = True
|
||||||
if status_changed:
|
if status_changed:
|
||||||
workflow_job.websocket_emit_status(workflow_job.status)
|
workflow_job.websocket_emit_status(workflow_job.status)
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# AWX
|
# 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.jobs import JobTemplate, Job
|
||||||
from awx.main.models.projects import ProjectUpdate
|
from awx.main.models.projects import ProjectUpdate
|
||||||
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
from awx.main.scheduler.dag_workflow import WorkflowDAG
|
||||||
from awx.api.versioning import reverse
|
from awx.api.versioning import reverse
|
||||||
|
from awx.api.views import WorkflowJobTemplateNodeSuccessNodesList
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
@@ -58,46 +64,100 @@ class TestWorkflowDAGFunctional(TransactionTestCase):
|
|||||||
def test_workflow_done(self):
|
def test_workflow_done(self):
|
||||||
wfj = self.workflow_job(states=['failed', None, None, 'successful', None])
|
wfj = self.workflow_job(states=['failed', None, None, 'successful', None])
|
||||||
dag = WorkflowDAG(workflow_job=wfj)
|
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.assertTrue(is_done)
|
||||||
self.assertFalse(has_failed)
|
self.assertFalse(has_failed)
|
||||||
|
assert reason is None
|
||||||
|
|
||||||
# verify that relaunched WFJ fails if a JT leaf is deleted
|
# verify that relaunched WFJ fails if a JT leaf is deleted
|
||||||
for jt in JobTemplate.objects.all():
|
for jt in JobTemplate.objects.all():
|
||||||
jt.delete()
|
jt.delete()
|
||||||
relaunched = wfj.create_relaunch_workflow_job()
|
relaunched = wfj.create_relaunch_workflow_job()
|
||||||
dag = WorkflowDAG(workflow_job=relaunched)
|
dag = WorkflowDAG(workflow_job=relaunched)
|
||||||
is_done, has_failed = dag.is_workflow_done()
|
dag.mark_dnr_nodes()
|
||||||
self.assertTrue(is_done)
|
is_done = dag.is_workflow_done()
|
||||||
self.assertTrue(has_failed)
|
has_failed, reason = dag.has_workflow_failed()
|
||||||
|
|
||||||
def test_workflow_fails_for_unfinished_node(self):
|
|
||||||
wfj = self.workflow_job(states=['error', None, None, None, None])
|
|
||||||
dag = WorkflowDAG(workflow_job=wfj)
|
|
||||||
is_done, has_failed = dag.is_workflow_done()
|
|
||||||
self.assertTrue(is_done)
|
self.assertTrue(is_done)
|
||||||
self.assertTrue(has_failed)
|
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):
|
def test_workflow_fails_for_no_error_handler(self):
|
||||||
wfj = self.workflow_job(states=['successful', 'failed', None, None, None])
|
wfj = self.workflow_job(states=['successful', 'failed', None, None, None])
|
||||||
dag = WorkflowDAG(workflow_job=wfj)
|
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(is_done)
|
||||||
self.assertTrue(has_failed)
|
self.assertTrue(has_failed)
|
||||||
|
|
||||||
def test_workflow_fails_leaf(self):
|
def test_workflow_fails_leaf(self):
|
||||||
wfj = self.workflow_job(states=['successful', 'successful', 'failed', None, None])
|
wfj = self.workflow_job(states=['successful', 'successful', 'failed', None, None])
|
||||||
dag = WorkflowDAG(workflow_job=wfj)
|
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(is_done)
|
||||||
self.assertTrue(has_failed)
|
self.assertTrue(has_failed)
|
||||||
|
|
||||||
def test_workflow_not_finished(self):
|
def test_workflow_not_finished(self):
|
||||||
wfj = self.workflow_job(states=['new', None, None, None, None])
|
wfj = self.workflow_job(states=['new', None, None, None, None])
|
||||||
dag = WorkflowDAG(workflow_job=wfj)
|
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(is_done)
|
||||||
self.assertFalse(has_failed)
|
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
|
@pytest.mark.django_db
|
||||||
@@ -186,18 +246,12 @@ class TestWorkflowJobTemplate:
|
|||||||
assert parent_qs[0] == wfjt.workflow_job_template_nodes.all()[1]
|
assert parent_qs[0] == wfjt.workflow_job_template_nodes.all()[1]
|
||||||
|
|
||||||
def test_topology_validator(self, wfjt):
|
def test_topology_validator(self, wfjt):
|
||||||
from awx.api.views import WorkflowJobTemplateNodeChildrenBaseList
|
test_view = WorkflowJobTemplateNodeSuccessNodesList()
|
||||||
test_view = WorkflowJobTemplateNodeChildrenBaseList()
|
|
||||||
nodes = wfjt.workflow_job_template_nodes.all()
|
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
|
# test cycle validation
|
||||||
assert test_view.is_valid_relation(node_assoc, nodes[0]) == {'Error': 'Cycle detected.'}
|
print(nodes[0].success_nodes.get(id=nodes[1].id).failure_nodes.get(id=nodes[2].id))
|
||||||
# test multi-ancestor validation
|
assert test_view.is_valid_relation(nodes[2], nodes[0]) == {'Error': 'Cycle detected.'}
|
||||||
assert test_view.is_valid_relation(node_assoc, nodes[1]) == {'Error': 'Multiple parent relationship not allowed.'}
|
|
||||||
# test mutex validation
|
|
||||||
test_view.relationship = 'failure_nodes'
|
|
||||||
|
|
||||||
def test_always_success_failure_creation(self, wfjt, admin, get):
|
def test_always_success_failure_creation(self, wfjt, admin, get):
|
||||||
wfjt_node = wfjt.workflow_job_template_nodes.all()[1]
|
wfjt_node = wfjt.workflow_job_template_nodes.all()[1]
|
||||||
node = WorkflowJobTemplateNode.objects.create(workflow_job_template=wfjt)
|
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,
|
"describe": false,
|
||||||
"moment": false,
|
"moment": false,
|
||||||
"spyOn": false,
|
"spyOn": false,
|
||||||
"jasmine": false
|
"jasmine": false,
|
||||||
|
"dagre": false
|
||||||
},
|
},
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"quotmark": false,
|
"quotmark": false,
|
||||||
|
|||||||
@@ -105,12 +105,14 @@ function TemplatesStrings (BaseString) {
|
|||||||
INVENTORY_SYNC: t.s('Inventory Sync'),
|
INVENTORY_SYNC: t.s('Inventory Sync'),
|
||||||
WORKFLOW: t.s('Workflow'),
|
WORKFLOW: t.s('Workflow'),
|
||||||
WARNING: t.s('Warning'),
|
WARNING: t.s('Warning'),
|
||||||
TOTAL_TEMPLATES: t.s('TOTAL TEMPLATES'),
|
TOTAL_NODES: t.s('TOTAL NODES'),
|
||||||
ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'),
|
ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'),
|
||||||
EDIT_TEMPLATE: t.s('EDIT TEMPLATE'),
|
EDIT_TEMPLATE: t.s('EDIT TEMPLATE'),
|
||||||
JOBS: t.s('JOBS'),
|
JOBS: t.s('JOBS'),
|
||||||
PLEASE_CLICK_THE_START_BUTTON: t.s('Please click the start button to build your workflow.'),
|
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.'),
|
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'),
|
RUN: t.s('RUN'),
|
||||||
CHECK: t.s('CHECK'),
|
CHECK: t.s('CHECK'),
|
||||||
SELECT: t.s('SELECT'),
|
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_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_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.'),
|
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'];
|
TemplatesStrings.$inject = ['BaseStringService'];
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import prompt from './prompt/main';
|
|||||||
import workflowChart from './workflows/workflow-chart/main';
|
import workflowChart from './workflows/workflow-chart/main';
|
||||||
import workflowMaker from './workflows/workflow-maker/main';
|
import workflowMaker from './workflows/workflow-maker/main';
|
||||||
import workflowControls from './workflows/workflow-controls/main';
|
import workflowControls from './workflows/workflow-controls/main';
|
||||||
import workflowService from './workflows/workflow.service';
|
|
||||||
import WorkflowForm from './workflows.form';
|
import WorkflowForm from './workflows.form';
|
||||||
import InventorySourcesList from './inventory-sources.list';
|
import InventorySourcesList from './inventory-sources.list';
|
||||||
import TemplateList from './templates.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
|
workflowChart.name, workflowMaker.name, workflowControls.name
|
||||||
])
|
])
|
||||||
.service('TemplatesService', templatesService)
|
.service('TemplatesService', templatesService)
|
||||||
.service('WorkflowService', workflowService)
|
|
||||||
.factory('WorkflowForm', WorkflowForm)
|
.factory('WorkflowForm', WorkflowForm)
|
||||||
// TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc.
|
// TODO: currently being kept arround for rbac selection, templates within projects and orgs, etc.
|
||||||
.factory('TemplateList', TemplateList)
|
.factory('TemplateList', TemplateList)
|
||||||
|
|||||||
@@ -133,10 +133,10 @@ export default ['Rest', 'GetBasePath', '$q', 'NextPage', function(Rest, GetBaseP
|
|||||||
getWorkflowJobTemplateNodes: function(id, page) {
|
getWorkflowJobTemplateNodes: function(id, page) {
|
||||||
var url = GetBasePath('workflow_job_templates');
|
var url = GetBasePath('workflow_job_templates');
|
||||||
|
|
||||||
url = url + id + '/workflow_nodes';
|
url = url + id + '/workflow_nodes?page_size=200';
|
||||||
|
|
||||||
if(page) {
|
if(page) {
|
||||||
url += '/?page=' + page;
|
url += '/&page=' + page;
|
||||||
}
|
}
|
||||||
|
|
||||||
Rest.setUrl(url);
|
Rest.setUrl(url);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
*************************************************/
|
*************************************************/
|
||||||
|
|
||||||
import workflowChart from './workflow-chart.directive';
|
import workflowChart from './workflow-chart.directive';
|
||||||
|
import workflowChartService from './workflow-chart.service';
|
||||||
|
|
||||||
export default
|
export default
|
||||||
angular.module('workflowChart', [])
|
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;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node .addCircle, .link .addCircle {
|
.WorkflowChart-node .WorkflowChart-addCircle, .WorkflowChart-link .WorkflowChart-addCircle {
|
||||||
fill: @default-succ;
|
fill: @default-succ;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addCircle.addHovering {
|
.WorkflowChart-addCircle.WorkflowChart-addHovering {
|
||||||
fill: @default-succ-hov;
|
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;
|
fill: @default-err;
|
||||||
}
|
}
|
||||||
|
|
||||||
.removeCircle.removeHovering {
|
.WorkflowChart-nodeRemoveCircle.removeHovering {
|
||||||
fill: @default-err-hov;
|
fill: @default-err-hov;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node {
|
.WorkflowChart-node .WorkflowChart-rect {
|
||||||
font-size: 12px;
|
fill: @default-secondary-bg;
|
||||||
font-family: 'Open Sans', sans-serif, 'FontAwesome';
|
}
|
||||||
|
|
||||||
|
.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 {
|
.WorkflowChart-defaultText {
|
||||||
@@ -32,72 +111,36 @@
|
|||||||
cursor: default;
|
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 {
|
.WorkflowChart-nodeTypeCircle {
|
||||||
fill: @default-icon;
|
fill: @default-icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-nodeTypeLetter {
|
.WorkflowChart-nodeTypeLetter {
|
||||||
fill: @default-bg;
|
fill: @default-bg;
|
||||||
}
|
}
|
||||||
.workflowChart-nodeStatus--running {
|
|
||||||
|
.WorkflowChart-nodeStatus--running {
|
||||||
fill: @default-icon;
|
fill: @default-icon;
|
||||||
}
|
}
|
||||||
.workflowChart-nodeStatus--success {
|
|
||||||
|
.WorkflowChart-nodeStatus--success {
|
||||||
fill: @default-succ;
|
fill: @default-succ;
|
||||||
}
|
}
|
||||||
.workflowChart-nodeStatus--failed, .workflowChart-nodeStatus--canceled {
|
|
||||||
|
.WorkflowChart-nodeStatus--failed, .WorkflowChart-nodeStatus--canceled {
|
||||||
fill: @default-err;
|
fill: @default-err;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-detailsLink {
|
.WorkflowChart-detailsLink {
|
||||||
fill: @default-link;
|
fill: @default-link;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-incompleteIcon {
|
.WorkflowChart-incompleteIcon {
|
||||||
color: @default-warning;
|
color: @default-warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-deletedText {
|
.WorkflowChart-deletedText {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
color: @default-interface-txt;
|
color: @default-interface-txt;
|
||||||
@@ -105,6 +148,7 @@
|
|||||||
.WorkflowChart-activeNode {
|
.WorkflowChart-activeNode {
|
||||||
fill: @default-link;
|
fill: @default-link;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-elapsedHolder {
|
.WorkflowChart-elapsedHolder {
|
||||||
background-color: @b7grey;
|
background-color: @b7grey;
|
||||||
color: @default-bg;
|
color: @default-bg;
|
||||||
@@ -113,26 +157,47 @@
|
|||||||
padding: 1px 3px;
|
padding: 1px 3px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowChart-nameText {
|
.WorkflowChart-nameText {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.WorkflowChart-tooltip {
|
||||||
|
pointer-events: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.WorkflowChart-tooltipContents {
|
.WorkflowChart-tooltipContents {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: #707070;
|
background-color: @default-interface-txt;
|
||||||
color: #FFFFFF;
|
color: @default-bg;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
max-width: 325px;
|
max-width: 325px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.WorkflowChart-tooltipArrow {
|
|
||||||
|
.WorkflowChart-tooltipArrow--down {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 10px solid transparent;
|
border-left: 10px solid transparent;
|
||||||
border-right: 10px solid transparent;
|
border-right: 10px solid transparent;
|
||||||
border-top: 10px solid #707070;
|
border-top: 10px solid @default-interface-txt;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.WorkflowChart-tooltipArrow {
|
||||||
|
fill: @default-interface-txt;
|
||||||
|
}
|
||||||
|
|
||||||
.WorkflowChart-dashedNode {
|
.WorkflowChart-dashedNode {
|
||||||
stroke-dasharray: 5,5;
|
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',
|
restrict: 'E',
|
||||||
link: function(scope) {
|
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.pan = function(direction) {
|
||||||
scope.panChart({
|
scope.panChart({
|
||||||
direction: direction
|
direction: direction
|
||||||
@@ -54,7 +38,7 @@ export default ['templateUrl',
|
|||||||
};
|
};
|
||||||
|
|
||||||
scope.zoomOut = function() {
|
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);
|
$("#slider").slider('value',scope.zoom);
|
||||||
scope.zoomChart({
|
scope.zoomChart({
|
||||||
zoom: scope.zoom
|
zoom: scope.zoom
|
||||||
@@ -70,7 +54,20 @@ export default ['templateUrl',
|
|||||||
$("#slider").slider('value',scope.zoom);
|
$("#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 workflowMaker from './workflow-maker.directive';
|
||||||
import WorkflowMakerController from './workflow-maker.controller';
|
import WorkflowMakerController from './workflow-maker.controller';
|
||||||
|
import workflowMakerForms from './forms/main';
|
||||||
|
|
||||||
export default
|
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
|
// 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?
|
// like so. Is this correct? Is there a better pattern for doing this?
|
||||||
.controller('WorkflowMakerController', WorkflowMakerController)
|
.controller('WorkflowMakerController', WorkflowMakerController)
|
||||||
|
|||||||
@@ -13,22 +13,26 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-title {
|
.WorkflowMaker-title {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-titleText {
|
.WorkflowMaker-titleText {
|
||||||
color: @list-title-txt;
|
color: @list-title-txt;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-exitHolder {
|
.WorkflowMaker-exitHolder {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-exit{
|
.WorkflowMaker-exit{
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
padding:0px;
|
padding:0px;
|
||||||
@@ -40,9 +44,11 @@
|
|||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
line-height:1;
|
line-height:1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-exit:hover{
|
.WorkflowMaker-exit:hover{
|
||||||
color:@default-icon;
|
color:@default-icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-contentHolder {
|
.WorkflowMaker-contentHolder {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid @b7grey;
|
border: 1px solid @b7grey;
|
||||||
@@ -50,11 +56,13 @@
|
|||||||
height: ~"calc(100% - 85px)";
|
height: ~"calc(100% - 85px)";
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-contentLeft {
|
.WorkflowMaker-contentLeft {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-contentRight {
|
.WorkflowMaker-contentRight {
|
||||||
flex: 0 0 400px;
|
flex: 0 0 400px;
|
||||||
border-left: 1px solid @b7grey;
|
border-left: 1px solid @b7grey;
|
||||||
@@ -63,12 +71,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-buttonHolder {
|
.WorkflowMaker-buttonHolder {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-saveButton{
|
.WorkflowMaker-saveButton{
|
||||||
background-color: @submit-button-bg;
|
background-color: @submit-button-bg;
|
||||||
color: @submit-button-text;
|
color: @submit-button-text;
|
||||||
@@ -117,46 +127,55 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-deleteModal {
|
.WorkflowMaker-deleteModal {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
width: 600px;
|
width: 600px;
|
||||||
background-color: @default-bg;
|
background-color: @default-bg;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formTitle {
|
.WorkflowMaker-formTitle {
|
||||||
color: @list-title-txt;
|
color: @list-title-txt;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formHelp {
|
.WorkflowMaker-formHelp {
|
||||||
color: @default-interface-txt;
|
color: @default-interface-txt;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formLists {
|
.WorkflowMaker-formLists {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
.SmartSearch-searchTermContainer {
|
.SmartSearch-searchTermContainer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formTitle {
|
.WorkflowMaker-formTitle {
|
||||||
display: flex;
|
display: flex;
|
||||||
color: @default-interface-txt;
|
color: @default-interface-txt;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formLabel {
|
.WorkflowMaker-formLabel {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formElement {
|
.WorkflowMaker-formElement {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-chart {
|
.WorkflowMaker-chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-totalJobs {
|
.WorkflowMaker-totalJobs {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-maker {
|
.WorkflowLegend-maker {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -165,33 +184,39 @@
|
|||||||
background: @default-bg;
|
background: @default-bg;
|
||||||
border-bottom: 1px solid @b7grey;
|
border-bottom: 1px solid @b7grey;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-maker--left {
|
.WorkflowLegend-maker--left {
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-maker--right {
|
.WorkflowLegend-maker--right {
|
||||||
flex: 0 0 217px;
|
flex: 0 0 217px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
padding-right: 20px;
|
padding-right: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-onSuccessLegend {
|
.WorkflowLegend-onSuccessLegend {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
background-color: @submit-button-bg;
|
background-color: @submit-button-bg;
|
||||||
margin: 18px 5px 18px 0px;
|
margin: 18px 5px 18px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-onFailLegend {
|
.WorkflowLegend-onFailLegend {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
background-color: @default-err;
|
background-color: @default-err;
|
||||||
margin: 18px 5px 18px 0px;
|
margin: 18px 5px 18px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-alwaysLegend {
|
.WorkflowLegend-alwaysLegend {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
background-color: @default-link;
|
background-color: @default-link;
|
||||||
margin: 18px 5px 18px 0px;
|
margin: 18px 5px 18px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-letterCircle{
|
.WorkflowLegend-letterCircle{
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -202,6 +227,7 @@
|
|||||||
margin: 10px 5px 10px 0px;
|
margin: 10px 5px 10px 0px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-details {
|
.WorkflowLegend-details {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -216,6 +242,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
flex: 1 0 auto;
|
flex: 1 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-details--right {
|
.WorkflowLegend-details--right {
|
||||||
flex: 0 0 44px;
|
flex: 0 0 44px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -230,15 +257,18 @@
|
|||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-menuIcon:hover,
|
.Key-menuIcon:hover,
|
||||||
.WorkflowMaker-manualControlsIcon:hover {
|
.WorkflowMaker-manualControlsIcon:hover {
|
||||||
color: @default-link-hov;
|
color: @default-link-hov;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-menuIcon--active,
|
.Key-menuIcon--active,
|
||||||
.WorkflowMaker-manualControlsIcon--active {
|
.WorkflowMaker-manualControlsIcon--active {
|
||||||
color: @default-link-hov;
|
color: @default-link-hov;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-manualControls {
|
.WorkflowMaker-manualControls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -106px;
|
left: -106px;
|
||||||
@@ -252,6 +282,7 @@
|
|||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowLegend-manualControls {
|
.WorkflowLegend-manualControls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -272px;
|
left: -272px;
|
||||||
@@ -263,18 +294,25 @@
|
|||||||
border: 1px solid @d7grey;
|
border: 1px solid @d7grey;
|
||||||
border-bottom-left-radius: 5px;
|
border-bottom-left-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-formTab {
|
.WorkflowMaker-formTab {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-preventBodyScrolling {
|
.WorkflowMaker-preventBodyScrolling {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.WorkflowMaker-invalidJobTemplateWarning {
|
.WorkflowMaker-invalidJobTemplateWarning {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
color: @default-err;
|
color: @default-err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.WorkflowMaker-readOnlyPromptText {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.Key-list {
|
.Key-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -282,15 +320,18 @@
|
|||||||
background-color: @default-bg;
|
background-color: @default-bg;
|
||||||
border: 1px solid @default-list-header-bg;
|
border: 1px solid @default-list-header-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-listItem {
|
.Key-listItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 5px 0 0 0;
|
margin: 5px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-listItemContent {
|
.Key-listItemContent {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-listItemContent--circle {
|
.Key-listItemContent--circle {
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
@@ -301,27 +342,34 @@
|
|||||||
margin: 9px 5px 9px 0px;
|
margin: 9px 5px 9px 0px;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-heading {
|
.Key-heading {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--success {
|
.Key-icon--success {
|
||||||
background-color: @submit-button-bg;
|
background-color: @submit-button-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--fail {
|
.Key-icon--fail {
|
||||||
background-color: @default-err;
|
background-color: @default-err;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--always {
|
.Key-icon--always {
|
||||||
background-color: @default-link;
|
background-color: @default-link;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--warning {
|
.Key-icon--warning {
|
||||||
background: @default-warning;
|
background: @default-warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--default {
|
.Key-icon--default {
|
||||||
background: @default-icon;
|
background: @default-icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-icon--circle {
|
.Key-icon--circle {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -331,6 +379,7 @@
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
margin: 4px 5px 5px 0px;
|
margin: 4px 5px 5px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Key-details {
|
.Key-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 40px;
|
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 class="WorkflowMaker-titleText">{{strings.get('workflow_maker.TITLE')}} | {{ workflowJobTemplateObj.name }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="WorkflowMaker-exitHolder">
|
<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>
|
<i class="fa fa-times-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,81 +73,40 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="WorkflowLegend-maker--right">
|
<div class="WorkflowLegend-maker--right">
|
||||||
<span class="WorkflowMaker-totalJobs">{{strings.get('workflow_maker.TOTAL_TEMPLATES')}}</span>
|
<span class="WorkflowMaker-totalJobs">{{strings.get('workflow_maker.TOTAL_NODES')}}</span>
|
||||||
<span class="badge List-titleBadge" ng-bind="treeData.data.totalNodes"></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>
|
<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">
|
<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>
|
<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>
|
</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>
|
||||||
<div class="WorkflowMaker-contentRight">
|
<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>
|
<span ng-if="formState.showNodeForm">
|
||||||
<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>
|
<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"/>
|
||||||
<div class="WorkflowMaker-form" ng-show="workflowMakerFormConfig.nodeMode === 'add' || workflowMakerFormConfig.nodeMode === 'edit'">
|
</span>
|
||||||
<div class="Form-tabHolder">
|
<span ng-if="formState.showLinkForm">
|
||||||
<div class="Form-tab WorkflowMaker-formTab" ng-class="{'is-selected': workflowMakerFormConfig.activeTab === 'jobs'}" ng-click="toggleFormTab('jobs')">{{strings.get('workflow_maker.JOBS')}}</div>
|
<workflow-link-form link-config="linkConfig" read-only="!workflowJobTemplateObj.summary_fields.user_capabilities.edit" select="confirmLinkForm(edgeType)" cancel="cancelLinkForm()" unlink="unlink()"/>
|
||||||
<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>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="WorkflowMaker-buttonHolder">
|
<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-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="workflowMakerFormConfig.nodeMode === 'add'"> {{:: strings.get('SAVE') }}</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>
|
</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>
|
</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('rrule');
|
||||||
require('sprintf-js');
|
require('sprintf-js');
|
||||||
require('reconnectingwebsocket');
|
require('reconnectingwebsocket');
|
||||||
|
global.dagre = require('dagre');
|
||||||
|
|
||||||
// D3 + extensions
|
// D3 + extensions
|
||||||
require('d3');
|
require('d3');
|
||||||
|
|||||||
@@ -157,3 +157,10 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-size: 11px;
|
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',
|
export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
||||||
'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange',
|
'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange',
|
||||||
'ParseVariableString', 'WorkflowService', 'count', '$state', 'i18n',
|
'ParseVariableString', 'count', '$state', 'i18n', 'WorkflowChartService', '$filter',
|
||||||
'moment', '$filter', function(workflowData, workflowResultsService,
|
'moment', function(workflowData, workflowResultsService,
|
||||||
workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange,
|
workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange,
|
||||||
ParseVariableString, WorkflowService, count, $state, i18n, moment, $filter) {
|
ParseVariableString, count, $state, i18n, WorkflowChartService, $filter,
|
||||||
|
moment) {
|
||||||
|
let nodeRef;
|
||||||
var runTimeElapsedTimer = null;
|
var runTimeElapsedTimer = null;
|
||||||
|
|
||||||
|
$scope.toggleKey = () => $scope.showKey = !$scope.showKey;
|
||||||
|
$scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`;
|
||||||
|
|
||||||
var getLinks = function() {
|
var getLinks = function() {
|
||||||
var getLink = function(key) {
|
var getLink = function(key) {
|
||||||
if(key === 'schedule') {
|
if(key === 'schedule') {
|
||||||
@@ -73,7 +78,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
|||||||
SHOW_MORE: i18n._('Show More'),
|
SHOW_MORE: i18n._('Show More'),
|
||||||
},
|
},
|
||||||
results: {
|
results: {
|
||||||
TOTAL_JOBS: i18n._('Total Jobs'),
|
TOTAL_NODES: i18n._('Total Nodes'),
|
||||||
ELAPSED: i18n._('Elapsed'),
|
ELAPSED: i18n._('Elapsed'),
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
@@ -113,11 +118,8 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
|||||||
$scope.workflow_nodes = workflowNodes;
|
$scope.workflow_nodes = workflowNodes;
|
||||||
$scope.workflowOptions = workflowDataOptions.actions.GET;
|
$scope.workflowOptions = workflowDataOptions.actions.GET;
|
||||||
$scope.labels = jobLabels;
|
$scope.labels = jobLabels;
|
||||||
$scope.count = count.val;
|
|
||||||
$scope.showManualControls = false;
|
$scope.showManualControls = false;
|
||||||
$scope.showKey = false;
|
$scope.readOnly = true;
|
||||||
$scope.toggleKey = () => $scope.showKey = !$scope.showKey;
|
|
||||||
$scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`;
|
|
||||||
|
|
||||||
// Start elapsed time updater for job known to be running
|
// Start elapsed time updater for job known to be running
|
||||||
if ($scope.workflow.started !== null && $scope.workflow.status === '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.varsTooltip= i18n._('Read only view of extra variables added to the workflow.');
|
||||||
$scope.varsLabel = i18n._('Extra Variables');
|
$scope.varsLabel = i18n._('Extra Variables');
|
||||||
|
|
||||||
|
|
||||||
// Click binding for the expand/collapse button on the standard out log
|
// Click binding for the expand/collapse button on the standard out log
|
||||||
$scope.stdoutFullScreen = false;
|
$scope.stdoutFullScreen = false;
|
||||||
|
|
||||||
WorkflowService.buildTree({
|
let arrayOfLinksForChart = [];
|
||||||
workflowNodes: workflowNodes
|
let arrayOfNodesForChart = [];
|
||||||
}).then(function(data){
|
|
||||||
$scope.treeData = data;
|
|
||||||
|
|
||||||
// TODO: I think that the workflow chart directive (and eventually d3) is meddling with
|
({arrayOfNodesForChart, arrayOfLinksForChart, nodeRef} = WorkflowChartService.generateArraysOfNodesAndLinks(workflowNodes));
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
$scope.graphState = { arrayOfNodesForChart, arrayOfLinksForChart };
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope.toggleStdoutFullscreen = function() {
|
$scope.toggleStdoutFullscreen = function() {
|
||||||
@@ -285,23 +277,16 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions',
|
|||||||
runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer);
|
runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
WorkflowService.updateStatusOfNode({
|
$scope.graphState.arrayOfNodesForChart.forEach((node) => {
|
||||||
treeData: $scope.treeData,
|
if (nodeRef[node.id] && nodeRef[node.id].originalNodeObject.id === data.workflow_node_id) {
|
||||||
nodeId: data.workflow_node_id,
|
node.job = {
|
||||||
status: data.status,
|
id: data.unified_job_id,
|
||||||
unified_job_id: data.unified_job_id
|
status: data.status,
|
||||||
});
|
type: nodeRef[node.id].unifiedJobTemplate.unified_job_type
|
||||||
|
|
||||||
$scope.workflow_nodes.forEach(node => {
|
|
||||||
if(parseInt(node.id) === parseInt(data.workflow_node_id)){
|
|
||||||
node.summary_fields.job = {
|
|
||||||
status: data.status
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$scope.count = workflowResultsService
|
|
||||||
.getCounts($scope.workflow_nodes);
|
|
||||||
$scope.$broadcast("refreshWorkflowChart");
|
$scope.$broadcast("refreshWorkflowChart");
|
||||||
}
|
}
|
||||||
getLabelsAndTooltips();
|
getLabelsAndTooltips();
|
||||||
|
|||||||
@@ -291,7 +291,7 @@
|
|||||||
<div class="WorkflowResults-badgeRow">
|
<div class="WorkflowResults-badgeRow">
|
||||||
<!-- PLAYS COUNT -->
|
<!-- PLAYS COUNT -->
|
||||||
<div class="WorkflowResults-badgeTitle">
|
<div class="WorkflowResults-badgeTitle">
|
||||||
{{ strings.results.TOTAL_JOBS }}
|
{{ strings.results.TOTAL_NODES }}
|
||||||
</div>
|
</div>
|
||||||
<span class="badge List-titleBadge">
|
<span class="badge List-titleBadge">
|
||||||
{{ workflow_nodes.length || 0}}
|
{{ workflow_nodes.length || 0}}
|
||||||
@@ -363,7 +363,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default {
|
|||||||
// flashing as rest data comes in. Provides the list of workflow nodes
|
// flashing as rest data comes in. Provides the list of workflow nodes
|
||||||
workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) {
|
workflowNodes: ['workflowData', 'Rest', '$q', function(workflowData, Rest, $q) {
|
||||||
var defer = $q.defer();
|
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()
|
Rest.get()
|
||||||
.then(({data}) => {
|
.then(({data}) => {
|
||||||
if(data.next) {
|
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",
|
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
|
||||||
"integrity": "sha1-vEZ0gAQ3iyGjYMn8fPUjF5B2L7g="
|
"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": {
|
"dashdash": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||||
@@ -6185,6 +6194,14 @@
|
|||||||
"integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=",
|
"integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=",
|
||||||
"dev": true
|
"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": {
|
"growl": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/growl/-/growl-1.9.2.tgz",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"codemirror": "^5.17.0",
|
"codemirror": "^5.17.0",
|
||||||
"components-font-awesome": "^4.6.1",
|
"components-font-awesome": "^4.6.1",
|
||||||
"d3": "^3.5.4",
|
"d3": "^3.5.4",
|
||||||
|
"dagre": "^0.8.2",
|
||||||
"hamsterjs": "^1.1.2",
|
"hamsterjs": "^1.1.2",
|
||||||
"html-entities": "^1.2.1",
|
"html-entities": "^1.2.1",
|
||||||
"inherits": "^1.0.2",
|
"inherits": "^1.0.2",
|
||||||
|
|||||||
@@ -14,31 +14,39 @@ const workflowSearchBar = "//input[contains(@class, 'SmartSearch-input')]";
|
|||||||
const workflowText = 'name.iexact:"test-actions-workflow-template"';
|
const workflowText = 'name.iexact:"test-actions-workflow-template"';
|
||||||
const workflowSearchBadgeCount = '//span[contains(@class, "at-Panel-headingTitleBadge") and contains(text(), "1")]';
|
const workflowSearchBadgeCount = '//span[contains(@class, "at-Panel-headingTitleBadge") and contains(text(), "1")]';
|
||||||
|
|
||||||
const rootNode = "//*[@id='node-2']";
|
const startNodeId = '1';
|
||||||
const childNode = "//*[@id='node-3']";
|
let initialJobNodeId;
|
||||||
const newChildNode = "//*[@id='node-5']";
|
let initialProjectNodeId;
|
||||||
const leafNode = "//*[@id='node-6']";
|
let initialInventoryNodeId;
|
||||||
const nodeAdd = "//*[contains(@class, 'nodeAddCross')]";
|
let newChildNodeId;
|
||||||
const nodeRemove = "//*[contains(@class, 'nodeRemoveCross')]";
|
let leafNodeId;
|
||||||
|
const nodeAdd = "//*[contains(@class, 'WorkflowChart-nodeAddIcon')]";
|
||||||
|
const nodeRemove = "//*[contains(@class, 'WorkflowChart-nodeRemoveIcon')]";
|
||||||
|
|
||||||
// one of the jobs or projects or inventories
|
// 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 testActionsJob = "//td[contains(text(), 'test-actions-job')]";
|
||||||
const testActionsProjectText = 'name.iexact:"test-actions-project"';
|
|
||||||
const testActionsJobText = 'name.iexact:"test-actions-job-template"';
|
const testActionsJobText = 'name.iexact:"test-actions-job-template"';
|
||||||
|
|
||||||
// search bar for visualizer templates
|
// search bar for visualizer templates
|
||||||
const jobSearchBar = "//*[contains(@id, 'workflow-jobs-list')]//input[contains(@class, 'SmartSearch-input')]";
|
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
|
// dropdown bar which lets you select edge type
|
||||||
const edgeTypeDropdownBar = "//span[contains(@id, 'select2-workflow_node_edge-container')]";
|
const edgeTypeDropdownBar = "//span[contains(@id, 'select2-workflow_node_edge-container')]";
|
||||||
const alwaysDropdown = "//li[contains(@id, 'select2-workflow_node_edge') and text()='Always']";
|
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 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 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 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 = {
|
module.exports = {
|
||||||
before: (client, done) => {
|
before: (client, done) => {
|
||||||
const resources = [
|
const resources = [
|
||||||
@@ -66,44 +74,66 @@ module.exports = {
|
|||||||
.waitForElementVisible(workflowSearchBadgeCount)
|
.waitForElementVisible(workflowSearchBadgeCount)
|
||||||
.waitForElementNotVisible(spinny)
|
.waitForElementNotVisible(spinny)
|
||||||
.findThenClick(workflowSelector)
|
.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
|
client
|
||||||
.useXpath()
|
.useXpath()
|
||||||
.findThenClick(rootNode)
|
.findThenClick(xPathNodeById(startNodeId))
|
||||||
.clearValue(projectSearchBar)
|
.waitForElementPresent(edgeTypeDropdownBar)
|
||||||
.setValue(projectSearchBar, [testActionsProjectText, client.Keys.ENTER])
|
|
||||||
.pause(1000)
|
|
||||||
.findThenClick(testActionsProject)
|
|
||||||
.findThenClick(edgeTypeDropdownBar)
|
.findThenClick(edgeTypeDropdownBar)
|
||||||
.waitForElementNotPresent(successDropdown)
|
.waitForElementNotPresent(successDropdown)
|
||||||
.waitForElementNotPresent(failureDropdown)
|
.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)
|
.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
|
client
|
||||||
.useXpath()
|
.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.pause(500);
|
||||||
client.waitForElementNotVisible(spinny);
|
client.waitForElementNotVisible(spinny);
|
||||||
// Concatenating the xpaths lets us click the proper node
|
// Concatenating the xpaths lets us click the proper node
|
||||||
client.click(childNode + nodeAdd);
|
client.click(xPathNodeById(initialJobNodeId) + nodeAdd);
|
||||||
})
|
})
|
||||||
.pause(1000)
|
.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)
|
.clearValue(jobSearchBar)
|
||||||
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
|
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
|
||||||
.pause(1000)
|
.pause(1000)
|
||||||
@@ -115,50 +145,47 @@ module.exports = {
|
|||||||
.waitForElementPresent(failureDropdown)
|
.waitForElementPresent(failureDropdown)
|
||||||
.waitForElementPresent(alwaysDropdown)
|
.waitForElementPresent(alwaysDropdown)
|
||||||
.findThenClick(alwaysDropdown)
|
.findThenClick(alwaysDropdown)
|
||||||
.click(selectButton);
|
.click(nodeSelectButton);
|
||||||
},
|
},
|
||||||
'Verify node-shifting behavior upon deletion': client => {
|
'Verify node-shifting behavior upon deletion': client => {
|
||||||
client
|
client
|
||||||
.findThenClick(newChildNode)
|
.moveToElement(xPathNodeById(newChildNodeId), 0, 0, () => {
|
||||||
.pause(1000)
|
|
||||||
.waitForElementNotVisible(spinny)
|
|
||||||
.findThenClick(edgeTypeDropdownBar)
|
|
||||||
.findThenClick(successDropdown)
|
|
||||||
.click(selectButton)
|
|
||||||
.moveToElement(newChildNode, 0, 0, () => {
|
|
||||||
client.pause(500);
|
client.pause(500);
|
||||||
client.waitForElementNotVisible(spinny);
|
client.waitForElementNotVisible(spinny);
|
||||||
client.click(newChildNode + nodeAdd);
|
client.click(xPathNodeById(newChildNodeId) + nodeAdd);
|
||||||
})
|
})
|
||||||
.pause(1000)
|
.pause(1000)
|
||||||
.waitForElementNotVisible(spinny)
|
.waitForElementNotVisible(spinny);
|
||||||
.clearValue(jobSearchBar)
|
|
||||||
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
|
// Grab the id of the new child node for later
|
||||||
.pause(1000)
|
client.getAttribute('//*[contains(@class, "WorkflowChart-isNodeBeingAdded")]/..', 'id', (res) => {
|
||||||
.findThenClick(testActionsJob)
|
// I had to nest this logic in order to ensure that leafNodeId was available later on.
|
||||||
.pause(1000)
|
// Separating this out resulted in leafNodeId being `undefined` when sent to
|
||||||
.waitForElementNotVisible(spinny)
|
// xPathLinkById
|
||||||
.findThenClick(edgeTypeDropdownBar)
|
leafNodeId = res.value.split('-')[1];
|
||||||
.waitForElementPresent(successDropdown)
|
client
|
||||||
.waitForElementPresent(failureDropdown)
|
.clearValue(jobSearchBar)
|
||||||
.waitForElementPresent(alwaysDropdown)
|
.setValue(jobSearchBar, [testActionsJobText, client.Keys.ENTER])
|
||||||
.findThenClick(alwaysDropdown)
|
.pause(1000)
|
||||||
.click(selectButton)
|
.findThenClick(testActionsJob)
|
||||||
.moveToElement(newChildNode, 0, 0, () => {
|
.pause(1000)
|
||||||
client.pause(500);
|
.waitForElementNotVisible(spinny)
|
||||||
client.waitForElementNotVisible(spinny);
|
.findThenClick(edgeTypeDropdownBar)
|
||||||
client.click(newChildNode + nodeRemove);
|
.waitForElementPresent(successDropdown)
|
||||||
})
|
.waitForElementPresent(failureDropdown)
|
||||||
.pause(1000)
|
.waitForElementPresent(alwaysDropdown)
|
||||||
.waitForElementNotVisible(spinny)
|
.findThenClick(alwaysDropdown)
|
||||||
.findThenClick(deleteConfirmation)
|
.click(nodeSelectButton)
|
||||||
.findThenClick(leafNode)
|
.moveToElement(xPathNodeById(newChildNodeId), 0, 0, () => {
|
||||||
.pause(1000)
|
client.pause(500);
|
||||||
.waitForElementNotVisible(spinny)
|
client.waitForElementNotVisible(spinny);
|
||||||
.findThenClick(edgeTypeDropdownBar)
|
client.click(xPathNodeById(newChildNodeId) + nodeRemove);
|
||||||
.waitForElementPresent(successDropdown)
|
})
|
||||||
.waitForElementPresent(failureDropdown)
|
.pause(1000)
|
||||||
.waitForElementPresent(alwaysDropdown);
|
.waitForElementNotVisible(spinny)
|
||||||
|
.findThenClick(deleteConfirmation)
|
||||||
|
.waitForElementVisible(xPathLinkById(initialJobNodeId, leafNodeId));
|
||||||
|
});
|
||||||
},
|
},
|
||||||
after: client => {
|
after: client => {
|
||||||
client.end();
|
client.end();
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ describe('Controller: workflowResults', () => {
|
|||||||
$provide.value('ParseVariableString', function() {});
|
$provide.value('ParseVariableString', function() {});
|
||||||
$provide.value('i18n', { '_': (a) => { return a; } });
|
$provide.value('i18n', { '_': (a) => { return a; } });
|
||||||
$provide.provider('$stateProvider', { '$get': function() { return function() {}; } });
|
$provide.provider('$stateProvider', { '$get': function() { return function() {}; } });
|
||||||
$provide.service('WorkflowService', function($q) {
|
$provide.service('WorkflowChartService', function($q) {
|
||||||
return {
|
return {
|
||||||
buildTree: function() {
|
generateArraysOfNodesAndLinks: function() {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
deferred.resolve(treeData);
|
deferred.resolve();
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,7 +46,6 @@ describe('Controller: workflowResults', () => {
|
|||||||
$rootScope = _$rootScope_;
|
$rootScope = _$rootScope_;
|
||||||
workflowResultsService = _workflowResultsService_;
|
workflowResultsService = _workflowResultsService_;
|
||||||
$interval = _$interval_;
|
$interval = _$interval_;
|
||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('elapsed timer', () => {
|
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.
|
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.
|
This is to prevent saturation of the task system with an infinite chain of workflows.
|
||||||
|
|
||||||
### Tree-Graph Formation and Restrictions
|
### DAG 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:
|
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.
|
||||||
* 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.
|
|
||||||
|
|
||||||
### Workflow Run Details
|
### 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.
|
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.
|
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.
|
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.
|
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:
|
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.
|
||||||
* 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).
|
|
||||||
|
|
||||||
### Workflow Copy and Relaunch
|
### 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.
|
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
|
### 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.
|
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
|
## Test Coverage
|
||||||
### CRUD-related
|
### 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.
|
* 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 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 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 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 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 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.
|
* Verify that `artifacts` is populated when `set_stats` is used in Ansible >= v2.2.1.0-0.3.rc3.
|
||||||
|
|||||||
Reference in New Issue
Block a user