From faa6ee47c5e84b50deb370b561d8c8f8c8acc962 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 27 Sep 2018 14:31:32 -0400 Subject: [PATCH 01/15] allow use of workflows in workflows --- awx/api/serializers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5427f52603..dad4437bd7 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3830,9 +3830,6 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): ujt_obj = attrs['unified_job_template'] elif self.instance: ujt_obj = self.instance.unified_job_template - if isinstance(ujt_obj, (WorkflowJobTemplate)): - raise serializers.ValidationError({ - "unified_job_template": _("Cannot nest a %s inside a WorkflowJobTemplate") % ujt_obj.__class__.__name__}) if 'credential' in deprecated_fields: # TODO: remove when v2 API is deprecated cred = deprecated_fields['credential'] attrs['credential'] = cred From 01d1470544fa922f10bab8af2ec001453797a3ac Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 1 Oct 2018 16:04:55 -0400 Subject: [PATCH 02/15] workflow variables processing, recursion detection --- awx/main/models/workflow.py | 26 ++++++++++++++++++++++---- awx/main/scheduler/task_manager.py | 25 ++++++++++++++++++++----- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index d02e3f6057..0753ae4992 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -31,7 +31,7 @@ from awx.main.models.mixins import ( SurveyJobMixin, RelatedJobsMixin, ) -from awx.main.models.jobs import LaunchTimeConfig +from awx.main.models.jobs import LaunchTimeConfig, JobTemplate from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField @@ -199,7 +199,14 @@ class WorkflowJobNode(WorkflowNodeBase): data = {} ujt_obj = self.unified_job_template if ujt_obj is not None: - accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**self.prompts_dict()) + # MERGE note: move this to prompts_dict method on node when merging + # with the workflow inventory branch + prompts_data = self.prompts_dict() + if isinstance(ujt_obj, WorkflowJobTemplate): + if self.workflow_job.extra_vars: + prompts_data.setdefault('extra_vars', {}) + prompts_data['extra_vars'].update(self.workflow_job.extra_vars_dict) + accepted_fields, ignored_fields, errors = ujt_obj._accept_or_ignore_job_kwargs(**prompts_data) if errors: logger.info(_('Bad launch configuration starting template {template_pk} as part of ' 'workflow {workflow_pk}. Errors:\n{error_text}').format( @@ -246,8 +253,9 @@ class WorkflowJobNode(WorkflowNodeBase): functional_aa_dict.pop('_ansible_no_log', None) extra_vars.update(functional_aa_dict) # Workflow Job extra_vars higher precedence than ancestor artifacts - if self.workflow_job and self.workflow_job.extra_vars: - extra_vars.update(self.workflow_job.extra_vars_dict) + if ujt_obj and isinstance(ujt_obj, JobTemplate): + if self.workflow_job and self.workflow_job.extra_vars: + extra_vars.update(self.workflow_job.extra_vars_dict) if extra_vars: data['extra_vars'] = extra_vars # ensure that unified jobs created by WorkflowJobs are marked @@ -505,6 +513,16 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio def task_impact(self): return 0 + def get_ancestor_workflows(self): + ancestors = [] + wj = self + while True: + wj = wj.get_workflow_job() + if (not wj) or (not wj.workflow_job_template_id): + break + ancestors.append(wj.workflow_job_template_id) + return ancestors + def get_notification_templates(self): return self.workflow_job_template.notification_templates diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 863352857c..4936ff74e0 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -26,6 +26,7 @@ from awx.main.models import ( ProjectUpdate, SystemJob, WorkflowJob, + WorkflowJobTemplate ) from awx.main.scheduler.dag_workflow import WorkflowDAG from awx.main.utils.pglock import advisory_lock @@ -120,7 +121,25 @@ class TaskManager(): spawn_node.job = job spawn_node.save() logger.info('Spawned %s in %s for node %s', job.log_format, workflow_job.log_format, spawn_node.pk) - if job._resources_sufficient_for_launch(): + can_start = True + if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate): + workflow_ancestors = job.get_ancestor_workflows() + if spawn_node.unified_job_template.id in set(workflow_ancestors): + can_start = False + logger.info('Refusing to start recursive workflow-in-workflow id={}, wfjt={}, ancestors={}'.format( + job.id, spawn_node.unified_job_template.id, workflow_ancestors)) + job.job_explanation = _( + "Workflow Job spawned from workflow could not start because it " + "would result in recursion (template spawn order {})" + ).format([spawn_node.unified_job_template.id] + workflow_ancestors) + else: + logger.debug('Starting workflow-in-workflow id={}, wfjt={}, ancestors={}'.format( + job.id, spawn_node.unified_job_template.id, workflow_ancestors)) + if not job._resources_sufficient_for_launch(): + can_start = False + job.job_explanation = _("Job spawned from workflow could not start because it " + "was missing a related resource such as project or inventory") + if can_start: if workflow_job.start_args: start_args = json.loads(decrypt_field(workflow_job, 'start_args')) else: @@ -129,10 +148,6 @@ class TaskManager(): if not can_start: job.job_explanation = _("Job spawned from workflow could not start because it " "was not in the right state or required manual credentials") - else: - can_start = False - job.job_explanation = _("Job spawned from workflow could not start because it " - "was missing a related resource such as project or inventory") if not can_start: job.status = 'failed' job.save(update_fields=['status', 'job_explanation']) From 5169fe348494b6333b79ffbc603ee344029af7a4 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 1 Oct 2018 16:35:50 -0400 Subject: [PATCH 03/15] safeguard against infinite loop if jobs have cycles --- awx/main/models/workflow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 0753ae4992..2b23b0adbc 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -516,8 +516,14 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio def get_ancestor_workflows(self): ancestors = [] wj = self + wj_ids = set([]) while True: + wj_ids.add(wj.id) wj = wj.get_workflow_job() + if wj.id in wj_ids: + logger.critical('Cycles detected in the workflow jobs graph, ' + 'this is not normal and suggests task manager degeneracy.') + break if (not wj) or (not wj.workflow_job_template_id): break ancestors.append(wj.workflow_job_template_id) From e225489f4348715a670215b5171c16a579d29192 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 2 Oct 2018 08:27:26 -0400 Subject: [PATCH 04/15] workflows-in-workflows add docs and tests --- awx/main/models/workflow.py | 18 ++++--- .../tests/functional/models/test_workflow.py | 52 +++++++++++++++++++ docs/workflow.md | 16 +++++- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 2b23b0adbc..60a5f151f2 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -514,19 +514,21 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio return 0 def get_ancestor_workflows(self): + """Returns a list of WFJTs that are indirect parents of this workflow job + say WFJTs are set up to spawn in order of A->B->C, and this workflow job + came from C, then C is the parent and [B, A] will be returned from this. + """ ancestors = [] - wj = self - wj_ids = set([]) - while True: - wj_ids.add(wj.id) - wj = wj.get_workflow_job() - if wj.id in wj_ids: + wj_ids = set([self.pk]) + wj = self.get_workflow_job() + while wj and wj.workflow_job_template_id: + if wj.pk in wj_ids: logger.critical('Cycles detected in the workflow jobs graph, ' 'this is not normal and suggests task manager degeneracy.') break - if (not wj) or (not wj.workflow_job_template_id): - break + wj_ids.add(wj.pk) ancestors.append(wj.workflow_job_template_id) + wj = wj.get_workflow_job() return ancestors def get_notification_templates(self): diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index c61c9a1926..5b57326242 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -215,3 +215,55 @@ class TestWorkflowJobTemplate: wfjt2.validate_unique() wfjt2 = WorkflowJobTemplate(name='foo', organization=None) wfjt2.validate_unique() + + +@pytest.mark.django_db +def test_workflow_ancestors(organization): + # Spawn order of templates grandparent -> parent -> child + # create child WFJT and workflow job + child = WorkflowJobTemplate.objects.create(organization=organization, name='child') + child_job = WorkflowJob.objects.create( + workflow_job_template=child, + launch_type='workflow' + ) + # create parent WFJT and workflow job, and link it up + parent = WorkflowJobTemplate.objects.create(organization=organization, name='parent') + parent_job = WorkflowJob.objects.create( + workflow_job_template=parent, + launch_type='workflow' + ) + WorkflowJobNode.objects.create( + workflow_job=parent_job, + unified_job_template=child, + job=child_job + ) + # create grandparent WFJT and workflow job and link it up + grandparent = WorkflowJobTemplate.objects.create(organization=organization, name='grandparent') + grandparent_job = WorkflowJob.objects.create( + workflow_job_template=grandparent, + launch_type='schedule' + ) + WorkflowJobNode.objects.create( + workflow_job=grandparent_job, + unified_job_template=parent, + job=parent_job + ) + # ancestors method gives a list of WFJT ids + assert child_job.get_ancestor_workflows() == [parent.pk, grandparent.pk] + + +@pytest.mark.django_db +def test_workflow_ancestors_recursion_prevention(organization): + # This is toxic database data, this tests that it doesn't create an infinite loop + wfjt = WorkflowJobTemplate.objects.create(organization=organization, name='child') + wfj = WorkflowJob.objects.create( + workflow_job_template=wfjt, + launch_type='workflow' + ) + WorkflowJobNode.objects.create( + workflow_job=wfj, + unified_job_template=wfjt, + job=wfj # well, this is a problem + ) + # mostly, we just care that this assertion finishes in finite time + assert wfj.get_ancestor_workflows() == [] diff --git a/docs/workflow.md b/docs/workflow.md index 7a16a70d57..6b3b713e14 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1,7 +1,7 @@ ## Tower Workflow Overview Workflows are structured compositions of Tower job resources. The only job of a workflow is to trigger other jobs in specific orders to achieve certain goals, such as tracking the full set of jobs that were part of a release process as a single unit. -A workflow has an associated tree-graph that is composed of multiple nodes. Each node in the tree has one associated job template (job template, inventory update, or project update) along with related resources that, if defined, will override the associated job template resources (i.e. credential, inventory, etc.) if the job template associated with the node is chosen to run. +A workflow has an associated tree-graph that is composed of multiple nodes. Each node in the tree has one associated job template (job template, inventory update, project update, or workflow job template) along with related resources that, if defined, will override the associated job template resources (i.e. credential, inventory, etc.) if the job template associated with the node is chosen to run. ## Usage Manual @@ -26,6 +26,18 @@ See the document on saved launch configurations for how these are processed when the job is launched, and the API validation involved in building the launch configurations on workflow nodes. +#### Workflows as Workflow Nodes + +A workflow can be added as a node in another workflow. The child workflow is the associated +`unified_job_template` that the node references, when that node is added to the parent workflow. +When the parent workflow dispatches that node, then the child workflow will begin running, and +the parent will resume execution of that branch when the child workflow finishes. +Branching into success / failed pathways is decided based on the status of the child workflow. + +In the event that spawning the workflow would result in recursion, the child workflow +will be marked as failed with a message explaining that recursion was detected. +This is to prevent saturation of the task system with an infinite chain of workflows. + ### Tree-Graph Formation and Restrictions The tree-graph structure of a workflow is enforced by associating workflow job template nodes via endpoints `/workflow_job_template_nodes/\d+/*_nodes/`, where `*` has options `success`, `failure` and `always`. However there are restrictions that must be enforced when setting up new connections. Here are the three restrictions that will raise validation error when break: * Cycle restriction: According to tree definition, no cycle is allowed. @@ -83,7 +95,7 @@ Artifact support starts in Ansible and is carried through in Tower. The `set_sta * No CRUD actions are possible on workflow job nodes by any user, and they may only be deleted by deleting their workflow job. * Workflow jobs can be deleted by superusers and org admins of the organization of its associated workflow job template, and no one else. * Verify that workflow job template nodes can be created under, or (dis)associated with workflow job templates. -* Verify that only 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 and projects*. +* Verify that the permitted types of job template types can be associated with a workflow job template node. Currently the permitted types are *job templates, inventory sources, projects, and workflow job templates*. * Verify that workflow job template nodes under the same workflow job template can be associated to form parent-child relationship of decision trees. In specific, one node takes another as its child node by POSTing another node's id to one of the three endpoints: `/success_nodes/`, `/failure_nodes/` and `/always_nodes/`. * Verify that workflow job template nodes are not allowed to have invalid association. Any attempt that causes invalidity will trigger 400-level response. The three types of invalid associations are cycle, convergence(multiple parent). * Verify that 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. From 4428dbf1ffc25fb085c40d1676bfa69ee9815c8d Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 2 Oct 2018 13:40:59 -0400 Subject: [PATCH 05/15] Allow use of role_level filter in UJT list --- awx/api/filters.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/awx/api/filters.py b/awx/api/filters.py index 8f883191f3..2c8ed0009d 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -25,7 +25,6 @@ from rest_framework.filters import BaseFilterBackend from awx.main.utils import get_type_for_model, to_python_boolean from awx.main.utils.db import get_all_field_names from awx.main.models.credential import CredentialType -from awx.main.models.rbac import RoleAncestorEntry class V1CredentialFilterBackend(BaseFilterBackend): @@ -347,12 +346,12 @@ class FieldLookupBackend(BaseFilterBackend): else: args.append(Q(**{k:v})) for role_name in role_filters: + if not hasattr(queryset.model, 'accessible_pk_qs'): + raise ParseError(_( + 'Cannot apply role_level filter to this list because its model ' + 'does not use roles for access control.')) args.append( - Q(pk__in=RoleAncestorEntry.objects.filter( - ancestor__in=request.user.roles.all(), - content_type_id=ContentType.objects.get_for_model(queryset.model).id, - role_field=role_name - ).values_list('object_id').distinct()) + Q(pk__in=queryset.model.accessible_pk_qs(request.user, role_name)) ) if or_filters: q = Q() From c2d4887043e8f80acc7fe4e0f26600b1971bfa78 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Sat, 6 Oct 2018 17:15:46 -0400 Subject: [PATCH 06/15] Include workflow jobs in workflow maker job templates list --- awx/ui/client/src/templates/main.js | 5 +++-- .../workflows/workflow-maker/workflow-maker.partial.html | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 61d04b6b82..85e3d08f59 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -419,7 +419,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p job_template_search: { value: { page_size: '5', - order_by: 'name' + order_by: 'name', + role_level: 'execute_role' }, squash: false, dynamic: true @@ -712,7 +713,7 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; list.iterator = 'job_template'; list.name = 'job_templates'; - list.basePath = "job_templates"; + list.basePath = 'unified_job_templates'; list.fields.info = { ngInclude: "'/static/partials/job-template-details.html'", type: 'template', diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index e27118163c..3f3fb5c14f 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -94,7 +94,7 @@
From f6cc351f7fc075b4e8590f2c6fbff1ddd6ccd3c7 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Sat, 6 Oct 2018 17:28:34 -0400 Subject: [PATCH 07/15] Format workflow-chart directive code --- .../workflow-chart.directive.js | 1954 +++++++++-------- 1 file changed, 1036 insertions(+), 918 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 75defa010f..8880c86321 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -4,994 +4,1112 @@ * All Rights Reserved *************************************************/ -export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'GetBasePath', 'ProcessErrors', 'TemplatesStrings', - function($state, moment, $timeout, $window, $filter, Rest, GetBasePath, ProcessErrors, TemplatesStrings) { +export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'GetBasePath', 'ProcessErrors', 'TemplatesStrings', + function ($state, moment, $timeout, $window, $filter, Rest, GetBasePath, ProcessErrors, TemplatesStrings) { - return { - scope: { - treeData: '=', - canAddWorkflowJobTemplate: '=', - workflowJobTemplateObj: '=', - addNode: '&', - editNode: '&', - deleteNode: '&', - workflowZoomed: '&', - mode: '@' - }, - restrict: 'E', - link: function(scope, element) { + return { + scope: { + treeData: '=', + canAddWorkflowJobTemplate: '=', + workflowJobTemplateObj: '=', + addNode: '&', + editNode: '&', + deleteNode: '&', + workflowZoomed: '&', + mode: '@' + }, + restrict: 'E', + link: function (scope, element) { - let marginLeft = 20, - i = 0, - nodeW = 180, - nodeH = 60, - rootW = 60, - rootH = 40, - startNodeOffsetY = scope.mode === 'details' ? 17 : 10, - verticalSpaceBetweenNodes = 20, - maxNodeTextLength = 27, - windowHeight, - windowWidth, - tree, - line, - zoomObj, - baseSvg, - svgGroup, - graphLoaded; + let marginLeft = 20, + i = 0, + nodeW = 180, + nodeH = 60, + rootW = 60, + rootH = 40, + startNodeOffsetY = scope.mode === 'details' ? 17 : 10, + verticalSpaceBetweenNodes = 20, + maxNodeTextLength = 27, + windowHeight, + windowWidth, + tree, + line, + zoomObj, + baseSvg, + svgGroup, + graphLoaded; - scope.dimensionsSet = false; + scope.dimensionsSet = false; - $timeout(function(){ - let dimensions = calcAvailableScreenSpace(); + $timeout(function () { + let dimensions = calcAvailableScreenSpace(); - windowHeight = dimensions.height; - windowWidth = dimensions.width; + windowHeight = dimensions.height; + windowWidth = dimensions.width; - $('.WorkflowMaker-chart').css("height", windowHeight); - $('.WorkflowMaker-chart').css("width", windowWidth); + $('.WorkflowMaker-chart').css("height", windowHeight); + $('.WorkflowMaker-chart').css("width", windowWidth); - scope.dimensionsSet = true; + scope.dimensionsSet = true; - init(); - }); + init(); + }); - function init() { - tree = d3.layout.tree() - .nodeSize([nodeH + verticalSpaceBetweenNodes,nodeW]) - .separation(function(a, b) { + function init() { + tree = d3.layout.tree() + .nodeSize([nodeH + verticalSpaceBetweenNodes, nodeW]) + .separation(function (a, b) { // This should tighten up some of the other nodes so there's not so much wasted space return a.parent === b.parent ? 1 : 1.25; }); - line = d3.svg.line() - .x(function(d){return d.x;}) - .y(function(d){return d.y;}); - - zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); - - baseSvg = d3.select(element[0]).append("svg") - .attr("class", "WorkflowChart-svg") - .call(zoomObj - .on("zoom", naturalZoom) - ); - - svgGroup = baseSvg.append("g") - .attr("id", "aw-workflow-chart-g") - .attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")"); - } - - function calcAvailableScreenSpace() { - let dimensions = {}; - - if(scope.mode !== 'details') { - // This is the workflow editor - dimensions.height = $('.WorkflowMaker-contentLeft').outerHeight() - $('.WorkflowLegend-maker').outerHeight(); - dimensions.width = $('#workflow-modal-dialog').width() - $('.WorkflowMaker-contentRight').outerWidth(); - } - else { - // This is the workflow details view - let panel = $('.WorkflowResults-rightSide').children('.Panel')[0]; - let panelWidth = $(panel).width(); - let panelHeight = $(panel).height(); - let headerHeight = $('.StandardOut-panelHeader').outerHeight(); - let legendHeight = $('.WorkflowLegend-details').outerHeight(); - let proposedHeight = panelHeight - headerHeight - legendHeight - 40; - - dimensions.height = proposedHeight > 200 ? proposedHeight : 200; - dimensions.width = panelWidth; - } - - return dimensions; - } - - function lineData(d){ - - let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; - let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; - let targetX = d.target.y; - let targetY = d.target.x + nodeH / 2; - - let points = [ - { - x: sourceX, - y: sourceY - }, - { - x: targetX, - y: targetY - } - ]; - - return line(points); - } - - // TODO: this function is hacky and we need to come up with a better solution - // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 - function wrap(text) { - if(text && text.length > maxNodeTextLength) { - return text.substring(0,maxNodeTextLength) + '...'; - } - else { - return text; - } - } - - function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { - var retval; - retval = "M" + (x + r) + "," + y; - retval += "h" + (w - 2*r); - if (tr) { retval += "a" + r + "," + r + " 0 0 1 " + r + "," + r; } - else { retval += "h" + r; retval += "v" + r; } - retval += "v" + (h - 2*r); - if (br) { retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + r; } - else { retval += "v" + r; retval += "h" + -r; } - retval += "h" + (2*r - w); - if (bl) { retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + -r; } - else { retval += "h" + -r; retval += "v" + -r; } - retval += "v" + (2*r - h); - if (tl) { retval += "a" + r + "," + r + " 0 0 1 " + r + "," + -r; } - else { retval += "v" + -r; retval += "h" + r; } - retval += "z"; - return retval; - } - - // This is the zoom function called by using the mousewheel/click and drag - function naturalZoom() { - let scale = d3.event.scale, - translation = d3.event.translate; - - translation = [translation[0] + (marginLeft*scale), translation[1] + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)]; - - svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); - - scope.workflowZoomed({ - zoom: scale - }); - } - - // This is the zoom that gets called when the user interacts with the manual zoom controls - function manualZoom(zoom) { - let scale = zoom / 100, - translation = zoomObj.translate(), - origZoom = zoomObj.scale(), - unscaledOffsetX = (translation[0] + ((windowWidth*origZoom) - windowWidth)/2)/origZoom, - unscaledOffsetY = (translation[1] + ((windowHeight*origZoom) - windowHeight)/2)/origZoom, - translateX = unscaledOffsetX*scale - ((scale*windowWidth)-windowWidth)/2, - translateY = unscaledOffsetY*scale - ((scale*windowHeight)-windowHeight)/2; - - svgGroup.attr("transform", "translate(" + [translateX + (marginLeft*scale), translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)] + ")scale(" + scale + ")"); - zoomObj.scale(scale); - zoomObj.translate([translateX, translateY]); - } - - function manualPan(direction) { - let scale = zoomObj.scale(), - distance = 150 * scale, - translateX, - translateY, - translateCoords = zoomObj.translate(); - if (direction === 'left' || direction === 'right') { - translateX = direction === 'left' ? translateCoords[0] - distance : translateCoords[0] + distance; - translateY = translateCoords[1]; - } else if (direction === 'up' || direction === 'down') { - translateX = translateCoords[0]; - translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; - } - svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scale)) + ")scale(" + scale + ")"); - zoomObj.translate([translateX, translateY]); - } - - function resetZoomAndPan() { - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - rootH/2 - startNodeOffsetY) + ")scale(" + 1 + ")"); - // Update the zoomObj - zoomObj.scale(1); - zoomObj.translate([0,0]); - } - - function zoomToFitChart() { - let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), - startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), - availableScreenSpace = calcAvailableScreenSpace(), - currentZoomValue = zoomObj.scale(), - unscaledH = graphDimensions.height/currentZoomValue, - unscaledW = graphDimensions.width/currentZoomValue, - scaleNeededForMaxHeight = (availableScreenSpace.height)/unscaledH, - scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft)/unscaledW, - lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), - scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10)/10), - startNodeOffsetFromGraphCenter = Math.round((((rootH/2) + (startNodeDimensions.top/currentZoomValue)) - ((graphDimensions.top/currentZoomValue) + (unscaledH/2)))*scaleToFit); - - manualZoom(scaleToFit*100); - - scope.workflowZoomed({ - zoom: scaleToFit - }); - - svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); - zoomObj.translate([marginLeft - scaleToFit*marginLeft, windowHeight/2 - (nodeH*scaleToFit/2) + startNodeOffsetFromGraphCenter - ((windowHeight/2 - rootH/2 - startNodeOffsetY)*scaleToFit)]); - - } - - function update() { - let userCanAddEdit = (scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; - if(scope.dimensionsSet) { - // Declare the nodes - let nodes = tree.nodes(scope.treeData), - links = tree.links(nodes); - let node = svgGroup.selectAll("g.node") - .data(nodes, function(d) { - d.y = d.depth * 240; - return d.id || (d.id = ++i); + line = d3.svg.line() + .x(function (d) { + return d.x; + }) + .y(function (d) { + return d.y; }); - let nodeEnter = node.enter().append("g") - .attr("class", "node") - .attr("id", function(d){return "node-" + d.id;}) - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); + zoomObj = d3.behavior.zoom().scaleExtent([0.5, 2]); - nodeEnter.each(function(d) { - let thisNode = d3.select(this); - if(d.isStartNode && scope.mode === 'details') { - // Overwrite the default root height and width and replace it with a small blue square - rootW = 25; - rootH = 25; - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", startNodeOffsetY) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#337ab7") - .attr("class", "WorkflowChart-rootNode"); + baseSvg = d3.select(element[0]).append("svg") + .attr("class", "WorkflowChart-svg") + .call(zoomObj + .on("zoom", naturalZoom) + ); + + svgGroup = baseSvg.append("g") + .attr("id", "aw-workflow-chart-g") + .attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - rootH / 2 - startNodeOffsetY) + ")"); + } + + function calcAvailableScreenSpace() { + let dimensions = {}; + + if (scope.mode !== 'details') { + // This is the workflow editor + dimensions.height = $('.WorkflowMaker-contentLeft').outerHeight() - $('.WorkflowLegend-maker').outerHeight(); + dimensions.width = $('#workflow-modal-dialog').width() - $('.WorkflowMaker-contentRight').outerWidth(); + } else { + // This is the workflow details view + let panel = $('.WorkflowResults-rightSide').children('.Panel')[0]; + let panelWidth = $(panel).width(); + let panelHeight = $(panel).height(); + let headerHeight = $('.StandardOut-panelHeader').outerHeight(); + let legendHeight = $('.WorkflowLegend-details').outerHeight(); + let proposedHeight = panelHeight - headerHeight - legendHeight - 40; + + dimensions.height = proposedHeight > 200 ? proposedHeight : 200; + dimensions.width = panelWidth; + } + + return dimensions; + } + + function lineData(d) { + + let sourceX = d.source.isStartNode ? d.source.y + rootW : d.source.y + nodeW; + let sourceY = d.source.isStartNode ? d.source.x + startNodeOffsetY + rootH / 2 : d.source.x + nodeH / 2; + let targetX = d.target.y; + let targetY = d.target.x + nodeH / 2; + + let points = [{ + x: sourceX, + y: sourceY + }, + { + x: targetX, + y: targetY } - else if(d.isStartNode && scope.mode !== 'details') { - thisNode.append("rect") - .attr("width", rootW) - .attr("height", rootH) - .attr("y", 10) - .attr("rx", 5) - .attr("ry", 5) - .attr("fill", "#5cb85c") - .attr("class", "WorkflowChart-rootNode") - .call(add_node); - thisNode.append("text") - .attr("x", 13) - .attr("y", 30) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-startText") - .text(function () { return TemplatesStrings.get('workflow_maker.START'); }) - .call(add_node); - } - else { - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("rx", 5) - .attr("ry", 5) - .attr('stroke', function(d) { - if(d.job && d.job.status) { - if(d.job.status === "successful"){ - return "#5cb85c"; - } - else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } - else { - return "#D7D7D7"; - } - } - else { - return "#D7D7D7"; - } - }) - .attr('stroke-width', "2px") - .attr("class", function(d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); + ]; - thisNode.append("path") - .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) - .attr("class", "WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); + return line(points); + } - thisNode.append("text") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) - .attr("dy", ".35em") - .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - }).each(wrap); + // TODO: this function is hacky and we need to come up with a better solution + // see: http://stackoverflow.com/questions/15975440/add-ellipses-to-overflowing-text-in-svg#answer-27723752 + function wrap(text) { + if (text && text.length > maxNodeTextLength) { + return text.substring(0, maxNodeTextLength) + '...'; + } else { + return text; + } + } - thisNode.append("foreignObject") - .attr("x", 62) - .attr("y", 22) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-defaultText WorkflowChart-deletedText") - .html(function () { - return `${TemplatesStrings.get('workflow_maker.DELETED')}`; - }) - .style("display", function(d) { return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); + function rounded_rect(x, y, w, h, r, tl, tr, bl, br) { + var retval; + retval = "M" + (x + r) + "," + y; + retval += "h" + (w - 2 * r); + if (tr) { + retval += "a" + r + "," + r + " 0 0 1 " + r + "," + r; + } else { + retval += "h" + r; + retval += "v" + r; + } + retval += "v" + (h - 2 * r); + if (br) { + retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + r; + } else { + retval += "v" + r; + retval += "h" + -r; + } + retval += "h" + (2 * r - w); + if (bl) { + retval += "a" + r + "," + r + " 0 0 1 " + -r + "," + -r; + } else { + retval += "h" + -r; + retval += "v" + -r; + } + retval += "v" + (2 * r - h); + if (tl) { + retval += "a" + r + "," + r + " 0 0 1 " + r + "," + -r; + } else { + retval += "v" + -r; + retval += "h" + r; + } + retval += "z"; + return retval; + } - thisNode.append("circle") - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "WorkflowChart-nodeTypeCircle") - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + // This is the zoom function called by using the mousewheel/click and drag + function naturalZoom() { + let scale = d3.event.scale, + translation = d3.event.translate; - thisNode.append("text") - .attr("y", nodeH) - .attr("dy", ".35em") - .attr("text-anchor", "middle") - .attr("class", "WorkflowChart-nodeTypeLetter") - .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); - }) - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); + translation = [translation[0] + (marginLeft * scale), translation[1] + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)]; - thisNode.append("rect") - .attr("width", nodeW) - .attr("height", nodeH) - .attr("class", "transparentRect") - .call(edit_node) - .on("mouseover", function(d) { - if(!d.isStartNode) { - let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; - if(resourceName && resourceName.length > maxNodeTextLength) { - // When the graph is initially rendered all the links come after the nodes (when you look at the dom). - // SVG components are painted in order of appearance. There is no concept of z-index, only the order. - // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top - // of the links and not underneath them. I tried rendering the links before the nodes but that lead to - // some weird link animation that I didn't care to try to fix. - svgGroup.selectAll("g.node").each(function() { - this.parentNode.appendChild(this); - }); - // After the nodes have been properly placed after the links, we need to make sure that the node that - // the user is hovering over is at the very end of the list. This way the tooltip will appear on top - // of all other nodes. - svgGroup.selectAll("g.node").sort(function (a) { - return (a.id !== d.id) ? -1 : 1; - }); - // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place - // it properly on the workflow - let tooltipDimensionChecker = $(""); - $('body').append(tooltipDimensionChecker); - let tipWidth = $(tooltipDimensionChecker).outerWidth(); - let tipHeight = $(tooltipDimensionChecker).outerHeight(); - $(tooltipDimensionChecker).remove(); + svgGroup.attr("transform", "translate(" + translation + ")scale(" + scale + ")"); - thisNode.append("foreignObject") - .attr("x", (nodeW / 2) - (tipWidth / 2)) - .attr("y", (tipHeight + 15) * -1) - .attr("width", tipWidth) - .attr("height", tipHeight+20) - .attr("class", "WorkflowChart-tooltip") - .html(function(){ - return "
" + $filter('sanitize')(resourceName) + "
"; - }); - } - d3.select("#node-" + d.id) - .classed("hovering", true); - } - }) - .on("mouseout", function(d){ - $('.WorkflowChart-tooltip').remove(); - if(!d.isStartNode) { - d3.select("#node-" + d.id) - .classed("hovering", false); - } - }); - thisNode.append("text") - .attr("x", nodeW - 45) - .attr("y", nodeH - 10) - .attr("dy", ".35em") - .attr("class", "WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }) - .text(function () { - return TemplatesStrings.get('workflow_maker.DETAILS'); - }) - .call(details); - thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-add";}) - .attr("cx", nodeW) - .attr("r", 10) - .attr("class", "addCircle nodeCircle") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - .call(add_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeAddCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + 0 + ")"; }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }) - .call(add_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-add") - .classed("addHovering", false); - }); - thisNode.append("circle") - .attr("id", function(d){return "node-" + d.id + "-remove";}) - .attr("cx", nodeW) - .attr("cy", nodeH) - .attr("r", 10) - .attr("class", "removeCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); - thisNode.append("path") - .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") - .style("fill", "white") - .attr("transform", function() { return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(remove_node) - .on("mouseover", function(d) { - d3.select("#node-" + d.id) - .classed("hovering", true); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#node-" + d.id) - .classed("hovering", false); - d3.select("#node-" + d.id + "-remove") - .classed("removeHovering", false); - }); + scope.workflowZoomed({ + zoom: scale + }); + } - thisNode.append("circle") - .attr("class", function(d) { + // This is the zoom that gets called when the user interacts with the manual zoom controls + function manualZoom(zoom) { + let scale = zoom / 100, + translation = zoomObj.translate(), + origZoom = zoomObj.scale(), + unscaledOffsetX = (translation[0] + ((windowWidth * origZoom) - windowWidth) / 2) / origZoom, + unscaledOffsetY = (translation[1] + ((windowHeight * origZoom) - windowHeight) / 2) / origZoom, + translateX = unscaledOffsetX * scale - ((scale * windowWidth) - windowWidth) / 2, + translateY = unscaledOffsetY * scale - ((scale * windowHeight) - windowHeight) / 2; - let statusClass = "WorkflowChart-nodeStatus "; + svgGroup.attr("transform", "translate(" + [translateX + (marginLeft * scale), translateY + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)] + ")scale(" + scale + ")"); + zoomObj.scale(scale); + zoomObj.translate([translateX, translateY]); + } - if(d.job){ - switch(d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; - break; - } - } + function manualPan(direction) { + let scale = zoomObj.scale(), + distance = 150 * scale, + translateX, + translateY, + translateCoords = zoomObj.translate(); + if (direction === 'left' || direction === 'right') { + translateX = direction === 'left' ? translateCoords[0] - distance : translateCoords[0] + distance; + translateY = translateCoords[1]; + } else if (direction === 'up' || direction === 'down') { + translateX = translateCoords[0]; + translateY = direction === 'up' ? translateCoords[1] - distance : translateCoords[1] + distance; + } + svgGroup.attr("transform", "translate(" + translateX + "," + (translateY + ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scale)) + ")scale(" + scale + ")"); + zoomObj.translate([translateX, translateY]); + } - return statusClass; - }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) - .attr("cy", 10) - .attr("cx", 10) - .attr("r", 6); + function resetZoomAndPan() { + svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - rootH / 2 - startNodeOffsetY) + ")scale(" + 1 + ")"); + // Update the zoomObj + zoomObj.scale(1); + zoomObj.translate([0, 0]); + } - thisNode.append("foreignObject") - .attr("x", 5) - .attr("y", 43) - .style("font-size","0.7em") - .attr("class", "WorkflowChart-elapsed") - .html(function (d) { - if(d.job && d.job.elapsed) { - let elapsedMs = d.job.elapsed * 1000; - let elapsedMoment = moment.duration(elapsedMs); - let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); - let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); - return "
" + elapsedString + "
"; - } - else { - return ""; - } - }) - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); - } + function zoomToFitChart() { + let graphDimensions = d3.select('#aw-workflow-chart-g')[0][0].getBoundingClientRect(), + startNodeDimensions = d3.select('.WorkflowChart-rootNode')[0][0].getBoundingClientRect(), + availableScreenSpace = calcAvailableScreenSpace(), + currentZoomValue = zoomObj.scale(), + unscaledH = graphDimensions.height / currentZoomValue, + unscaledW = graphDimensions.width / currentZoomValue, + scaleNeededForMaxHeight = (availableScreenSpace.height) / unscaledH, + scaleNeededForMaxWidth = (availableScreenSpace.width - marginLeft) / unscaledW, + lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth), + scaleToFit = lowerScale < 0.5 ? 0.5 : (lowerScale > 2 ? 2 : Math.floor(lowerScale * 10) / 10), + startNodeOffsetFromGraphCenter = Math.round((((rootH / 2) + (startNodeDimensions.top / currentZoomValue)) - ((graphDimensions.top / currentZoomValue) + (unscaledH / 2))) * scaleToFit); + + manualZoom(scaleToFit * 100); + + scope.workflowZoomed({ + zoom: scaleToFit }); - node.exit().remove(); + svgGroup.attr("transform", "translate(" + marginLeft + "," + (windowHeight / 2 - (nodeH * scaleToFit / 2) + startNodeOffsetFromGraphCenter) + ")scale(" + scaleToFit + ")"); + zoomObj.translate([marginLeft - scaleToFit * marginLeft, windowHeight / 2 - (nodeH * scaleToFit / 2) + startNodeOffsetFromGraphCenter - ((windowHeight / 2 - rootH / 2 - startNodeOffsetY) * scaleToFit)]); - if(nodes && nodes.length > 1 && !graphLoaded) { - zoomToFitChart(); - } + } - graphLoaded = true; + function update() { + let userCanAddEdit = (scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate; + if (scope.dimensionsSet) { + // Declare the nodes + let nodes = tree.nodes(scope.treeData), + links = tree.links(nodes); + let node = svgGroup.selectAll("g.node") + .data(nodes, function (d) { + d.y = d.depth * 240; + return d.id || (d.id = ++i); + }); - let link = svgGroup.selectAll("g.link") - .data(links, function(d) { - return d.source.id + "-" + d.target.id; + let nodeEnter = node.enter().append("g") + .attr("class", "node") + .attr("id", function (d) { + return "node-" + d.id; + }) + .attr("parent", function (d) { + return d.parent ? d.parent.id : null; + }) + .attr("transform", function (d) { + return "translate(" + d.y + "," + d.x + ")"; + }); + + nodeEnter.each(function (d) { + let thisNode = d3.select(this); + if (d.isStartNode && scope.mode === 'details') { + // Overwrite the default root height and width and replace it with a small blue square + rootW = 25; + rootH = 25; + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + .attr("y", startNodeOffsetY) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#337ab7") + .attr("class", "WorkflowChart-rootNode"); + } else if (d.isStartNode && scope.mode !== 'details') { + thisNode.append("rect") + .attr("width", rootW) + .attr("height", rootH) + .attr("y", 10) + .attr("rx", 5) + .attr("ry", 5) + .attr("fill", "#5cb85c") + .attr("class", "WorkflowChart-rootNode") + .call(add_node); + thisNode.append("text") + .attr("x", 13) + .attr("y", 30) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-startText") + .text(function () { + return TemplatesStrings.get('workflow_maker.START'); + }) + .call(add_node); + } else { + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("rx", 5) + .attr("ry", 5) + .attr('stroke', function (d) { + if (d.job && d.job.status) { + if (d.job.status === "successful") { + return "#5cb85c"; + } else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } else { + return "#D7D7D7"; + } + } else { + return "#D7D7D7"; + } + }) + .attr('stroke-width', "2px") + .attr("class", function (d) { + let classString = d.placeholder ? "rect placeholder" : "rect"; + classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + return classString; + }); + + thisNode.append("path") + .attr("d", rounded_rect(1, 0, 5, nodeH, 5, 1, 0, 1, 0)) + .attr("class", "WorkflowChart-activeNode") + .style("display", function (d) { + return d.isActiveEdit ? null : "none"; + }); + + thisNode.append("text") + .attr("x", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; + }) + .attr("y", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; + }) + .attr("dy", ".35em") + .attr("text-anchor", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; + }) + .attr("class", "WorkflowChart-defaultText WorkflowChart-nameText") + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; + }).each(wrap); + + thisNode.append("foreignObject") + .attr("x", 54) + .attr("y", 45) + .style("font-size", "0.7em") + .attr("class", "WorkflowChart-conflictText") + .html(function () { + return `\uf06a ${TemplatesStrings.get('workflow_maker.EDGE_CONFLICT')}`; + }) + .style("display", function (d) { + return (d.edgeConflict && !d.placeholder) ? null : "none"; + }); + + thisNode.append("foreignObject") + .attr("x", 62) + .attr("y", 22) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-defaultText WorkflowChart-deletedText") + .html(function () { + return `${TemplatesStrings.get('workflow_maker.DELETED')}`; + }) + .style("display", function (d) { + return d.unifiedJobTemplate || d.placeholder ? "none" : null; + }); + + thisNode.append("circle") + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "WorkflowChart-nodeTypeCircle") + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + }); + + thisNode.append("text") + .attr("y", nodeH) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .attr("class", "WorkflowChart-nodeTypeLetter") + .text(function (d) { + return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + }) + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + }); + + thisNode.append("rect") + .attr("width", nodeW) + .attr("height", nodeH) + .attr("class", "transparentRect") + .call(edit_node) + .on("mouseover", function (d) { + if (!d.isStartNode) { + let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; + if (resourceName && resourceName.length > maxNodeTextLength) { + // When the graph is initially rendered all the links come after the nodes (when you look at the dom). + // SVG components are painted in order of appearance. There is no concept of z-index, only the order. + // As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top + // of the links and not underneath them. I tried rendering the links before the nodes but that lead to + // some weird link animation that I didn't care to try to fix. + svgGroup.selectAll("g.node").each(function () { + this.parentNode.appendChild(this); + }); + // After the nodes have been properly placed after the links, we need to make sure that the node that + // the user is hovering over is at the very end of the list. This way the tooltip will appear on top + // of all other nodes. + svgGroup.selectAll("g.node").sort(function (a) { + return (a.id !== d.id) ? -1 : 1; + }); + // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place + // it properly on the workflow + let tooltipDimensionChecker = $(""); + $('body').append(tooltipDimensionChecker); + let tipWidth = $(tooltipDimensionChecker).outerWidth(); + let tipHeight = $(tooltipDimensionChecker).outerHeight(); + $(tooltipDimensionChecker).remove(); + + thisNode.append("foreignObject") + .attr("x", (nodeW / 2) - (tipWidth / 2)) + .attr("y", (tipHeight + 15) * -1) + .attr("width", tipWidth) + .attr("height", tipHeight + 20) + .attr("class", "WorkflowChart-tooltip") + .html(function () { + return "
" + $filter('sanitize')(resourceName) + "
"; + }); + } + d3.select("#node-" + d.id) + .classed("hovering", true); + } + }) + .on("mouseout", function (d) { + $('.WorkflowChart-tooltip').remove(); + if (!d.isStartNode) { + d3.select("#node-" + d.id) + .classed("hovering", false); + } + }); + thisNode.append("text") + .attr("x", nodeW - 45) + .attr("y", nodeH - 10) + .attr("dy", ".35em") + .attr("class", "WorkflowChart-detailsLink") + .style("display", function (d) { + return d.job && d.job.status && d.job.id ? null : "none"; + }) + .text(function () { + return TemplatesStrings.get('workflow_maker.DETAILS'); + }) + .call(details); + thisNode.append("circle") + .attr("id", function (d) { + return "node-" + d.id + "-add"; + }) + .attr("cx", nodeW) + .attr("r", 10) + .attr("class", "addCircle nodeCircle") + .style("display", function (d) { + return d.placeholder || !(userCanAddEdit) ? "none" : null; + }) + .call(add_node) + .on("mouseover", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeAddCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function () { + return "translate(" + nodeW + "," + 0 + ")"; + }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function (d) { + return d.placeholder || !(userCanAddEdit) ? "none" : null; + }) + .call(add_node) + .on("mouseover", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-add") + .classed("addHovering", false); + }); + thisNode.append("circle") + .attr("id", function (d) { + return "node-" + d.id + "-remove"; + }) + .attr("cx", nodeW) + .attr("cy", nodeH) + .attr("r", 10) + .attr("class", "removeCircle") + .style("display", function (d) { + return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .call(remove_node) + .on("mouseover", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + thisNode.append("path") + .attr("class", "nodeRemoveCross WorkflowChart-hoverPath") + .style("fill", "white") + .attr("transform", function () { + return "translate(" + nodeW + "," + nodeH + ") rotate(-45)"; + }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function (d) { + return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .call(remove_node) + .on("mouseover", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", true); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#node-" + d.id) + .classed("hovering", false); + d3.select("#node-" + d.id + "-remove") + .classed("removeHovering", false); + }); + + thisNode.append("circle") + .attr("class", function (d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if (d.job) { + switch (d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "canceled": + statusClass += "workflowChart-nodeStatus--canceled"; + break; + } + } + + return statusClass; + }) + .style("display", function (d) { + return d.job && d.job.status ? null : "none"; + }) + .attr("cy", 10) + .attr("cx", 10) + .attr("r", 6); + + thisNode.append("foreignObject") + .attr("x", 5) + .attr("y", 43) + .style("font-size", "0.7em") + .attr("class", "WorkflowChart-elapsed") + .html(function (d) { + if (d.job && d.job.elapsed) { + let elapsedMs = d.job.elapsed * 1000; + let elapsedMoment = moment.duration(elapsedMs); + let paddedElapsedMoment = Math.floor(elapsedMoment.asHours()) < 10 ? "0" + Math.floor(elapsedMoment.asHours()) : Math.floor(elapsedMoment.asHours()); + let elapsedString = paddedElapsedMoment + moment.utc(elapsedMs).format(":mm:ss"); + return "
" + elapsedString + "
"; + } else { + return ""; + } + }) + .style("display", function (d) { + return (d.job && d.job.elapsed) ? null : "none"; + }); + } }); - let linkEnter = link.enter().append("g") - .attr("class", "link") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id;}); + node.exit().remove(); - // Add entering links in the parent’s old position. - linkEnter.insert("path", "g") - .attr("class", function(d) { - return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; - }) - .attr("d", lineData) - .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { - return "#d9534f"; - } - else if(d.target.edgeType === "success") { - return "#5cb85c"; - } - else if(d.target.edgeType === "always"){ - return "#337ab7"; - } - } - else { - return "#D7D7D7"; - } - }); + if (nodes && nodes.length > 1 && !graphLoaded) { + zoomToFitChart(); + } - linkEnter.append("circle") - .attr("id", function(d){return "link-" + d.source.id + "-" + d.target.id + "-add";}) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }) - .attr("r", 10) - .attr("class", "addCircle linkCircle") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); + graphLoaded = true; - linkEnter.append("path") - .attr("class", "linkCross") - .style("fill", "white") - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }) - .attr("d", d3.svg.symbol() - .size(60) - .type("cross") - ) - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .call(add_node_between) - .on("mouseover", function(d) { - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", true); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", true); - }) - .on("mouseout", function(d){ - d3.select("#link-" + d.source.id + "-" + d.target.id) - .classed("hovering", false); - d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") - .classed("addHovering", false); - }); + let link = svgGroup.selectAll("g.link") + .data(links, function (d) { + return d.source.id + "-" + d.target.id; + }); - link.exit().remove(); + let linkEnter = link.enter().append("g") + .attr("class", "link") + .attr("id", function (d) { + return "link-" + d.source.id + "-" + d.target.id; + }); - // Transition nodes and links to their new positions. - let t = baseSvg.transition(); - - t.selectAll(".nodeCircle") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - - t.selectAll(".nodeAddCross") - .style("display", function(d) { return d.placeholder || !(userCanAddEdit) ? "none" : null; }); - - t.selectAll(".removeCircle") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - - t.selectAll(".nodeRemoveCross") - .style("display", function(d) { return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; }); - - t.selectAll(".linkPath") - .attr("class", function(d) { + // Add entering links in the parent’s old position. + linkEnter.insert("path", "g") + .attr("class", function (d) { return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; }) .attr("d", lineData) - .attr('stroke', function(d) { - if(d.target.edgeType) { - if(d.target.edgeType === "failure") { + .attr('stroke', function (d) { + if (d.target.edgeType) { + if (d.target.edgeType === "failure") { return "#d9534f"; - } - else if(d.target.edgeType === "success") { + } else if (d.target.edgeType === "success") { return "#5cb85c"; - } - else if(d.target.edgeType === "always"){ + } else if (d.target.edgeType === "always") { return "#337ab7"; } - } - else { + } else { return "#D7D7D7"; } }); - t.selectAll(".linkCircle") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .attr("cx", function(d) { - return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; - }) - .attr("cy", function(d) { - return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; - }); + linkEnter.append("circle") + .attr("id", function (d) { + return "link-" + d.source.id + "-" + d.target.id + "-add"; + }) + .attr("cx", function (d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function (d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }) + .attr("r", 10) + .attr("class", "addCircle linkCircle") + .style("display", function (d) { + return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .call(add_node_between) + .on("mouseover", function (d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", true); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", false); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", false); + }); - t.selectAll(".linkCross") - .style("display", function(d) { return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; }) - .attr("transform", function(d) { - let translate; - if(d.source.isStartNode) { - translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH/2) + (d.source.x + nodeH/2)) / 2 + ")"; - } - else { - translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; - } - return translate; - }); + linkEnter.append("path") + .attr("class", "linkCross") + .style("fill", "white") + .attr("transform", function (d) { + let translate; + if (d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 + ")"; + } else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; + } + return translate; + }) + .attr("d", d3.svg.symbol() + .size(60) + .type("cross") + ) + .style("display", function (d) { + return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .call(add_node_between) + .on("mouseover", function (d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", true); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", true); + }) + .on("mouseout", function (d) { + d3.select("#link-" + d.source.id + "-" + d.target.id) + .classed("hovering", false); + d3.select("#link-" + d.source.id + "-" + d.target.id + "-add") + .classed("addHovering", false); + }); - t.selectAll(".rect") - .attr('stroke', function(d) { - if(d.job && d.job.status) { - if(d.job.status === "successful"){ - return "#5cb85c"; - } - else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { - return "#d9534f"; - } - else { + link.exit().remove(); + + // Transition nodes and links to their new positions. + let t = baseSvg.transition(); + + t.selectAll(".nodeCircle") + .style("display", function (d) { + return d.placeholder || !(userCanAddEdit) ? "none" : null; + }); + + t.selectAll(".nodeAddCross") + .style("display", function (d) { + return d.placeholder || !(userCanAddEdit) ? "none" : null; + }); + + t.selectAll(".removeCircle") + .style("display", function (d) { + return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; + }); + + t.selectAll(".nodeRemoveCross") + .style("display", function (d) { + return (d.canDelete === false || d.placeholder || !(userCanAddEdit)) ? "none" : null; + }); + + t.selectAll(".linkPath") + .attr("class", function (d) { + return (d.source.placeholder || d.target.placeholder) ? "linkPath placeholder" : "linkPath"; + }) + .attr("d", lineData) + .attr('stroke', function (d) { + if (d.target.edgeType) { + if (d.target.edgeType === "failure") { + return "#d9534f"; + } else if (d.target.edgeType === "success") { + return "#5cb85c"; + } else if (d.target.edgeType === "always") { + return "#337ab7"; + } + } else { return "#D7D7D7"; } - } - else { - return "#D7D7D7"; - } - }) - .attr("class", function(d) { - let classString = d.placeholder ? "rect placeholder" : "rect"; - classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; - return classString; - }); + }); - t.selectAll(".node") - .attr("parent", function(d){return d.parent ? d.parent.id : null;}) - .attr("transform", function(d) {d.px = d.x; d.py = d.y; return "translate(" + d.y + "," + d.x + ")"; }); + t.selectAll(".linkCircle") + .style("display", function (d) { + return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .attr("cx", function (d) { + return (d.source.isStartNode) ? (d.target.y + d.source.y + rootW) / 2 : (d.target.y + d.source.y + nodeW) / 2; + }) + .attr("cy", function (d) { + return (d.source.isStartNode) ? ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 : (d.target.x + d.source.x + nodeH) / 2; + }); - t.selectAll(".WorkflowChart-nodeTypeCircle") - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update" ) ? null : "none"; }); - - t.selectAll(".WorkflowChart-nodeTypeLetter") - .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); - }) - .style("display", function(d) { return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; }); - - t.selectAll(".WorkflowChart-nodeStatus") - .attr("class", function(d) { - - let statusClass = "WorkflowChart-nodeStatus "; - - if(d.job){ - switch(d.job.status) { - case "pending": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "waiting": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "running": - statusClass += "workflowChart-nodeStatus--running"; - break; - case "successful": - statusClass += "workflowChart-nodeStatus--success"; - break; - case "failed": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "error": - statusClass += "workflowChart-nodeStatus--failed"; - break; - case "canceled": - statusClass += "workflowChart-nodeStatus--canceled"; - break; + t.selectAll(".linkCross") + .style("display", function (d) { + return (d.source.placeholder || d.target.placeholder || !(userCanAddEdit)) ? "none" : null; + }) + .attr("transform", function (d) { + let translate; + if (d.source.isStartNode) { + translate = "translate(" + (d.target.y + d.source.y + rootW) / 2 + "," + ((d.target.x + startNodeOffsetY + rootH / 2) + (d.source.x + nodeH / 2)) / 2 + ")"; + } else { + translate = "translate(" + (d.target.y + d.source.y + nodeW) / 2 + "," + (d.target.x + d.source.x + nodeH) / 2 + ")"; } - } + return translate; + }); - return statusClass; - }) - .style("display", function(d) { return d.job && d.job.status ? null : "none"; }) - .transition() - .duration(0) - .attr("r", 6) - .each(function(d) { - if(d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { - // Pulse the circle - var circle = d3.select(this); - (function repeat() { - circle = circle.transition() - .duration(2000) - .attr("r", 6) - .transition() - .duration(2000) - .attr("r", 0) - .ease('sine') - .each("end", repeat); - })(); - } - }); - - t.selectAll(".WorkflowChart-nameText") - .attr("x", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; }) - .attr("y", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; }) - .attr("text-anchor", function(d){ return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; }) - .text(function (d) { - return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; - }); - - t.selectAll(".WorkflowChart-detailsLink") - .style("display", function(d){ return d.job && d.job.status && d.job.id ? null : "none"; }); - - t.selectAll(".WorkflowChart-deletedText") - .style("display", function(d){ return d.unifiedJobTemplate || d.placeholder ? "none" : null; }); - - t.selectAll(".WorkflowChart-activeNode") - .style("display", function(d) { return d.isActiveEdit ? null : "none"; }); - - t.selectAll(".WorkflowChart-elapsed") - .style("display", function(d) { return (d.job && d.job.elapsed) ? null : "none"; }); - } - else if(!scope.watchDimensionsSet){ - scope.watchDimensionsSet = scope.$watch('dimensionsSet', function(){ - if(scope.dimensionsSet) { - scope.watchDimensionsSet(); - scope.watchDimensionsSet = null; - update(); - } - }); - } - } - - function add_node() { - this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: false - }); - } - }); - } - - function add_node_between() { - this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.addNode({ - parent: d, - betweenTwoNodes: true - }); - } - }); - } - - function remove_node() { - this.on("click", function(d) { - if((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { - scope.deleteNode({ - nodeToDelete: d - }); - } - }); - } - - function edit_node() { - this.on("click", function(d) { - if(d.canEdit){ - scope.editNode({ - nodeToEdit: d - }); - } - }); - } - - function details() { - this.on("mouseover", function() { - d3.select(this).style("text-decoration", "underline"); - }); - this.on("mouseout", function() { - d3.select(this).style("text-decoration", null); - }); - this.on("click", function(d) { - - let goToJobResults = function(job_type) { - if(job_type === 'job') { - $state.go('output', {id: d.job.id, type: 'playbook'}); - } - else if(job_type === 'inventory_update') { - $state.go('output', {id: d.job.id, type: 'inventory'}); - } - else if(job_type === 'project_update') { - $state.go('output', {id: d.job.id, type: 'project'}); - } - }; - - if(d.job.id) { - if(d.unifiedJobTemplate) { - goToJobResults(d.unifiedJobTemplate.unified_job_type); - } - else { - // We don't have access to the unified resource and have to make - // a GET request in order to find out what type job this was - // so that we can route the user to the correct stdout view - - Rest.setUrl(GetBasePath("unified_jobs") + "?id=" + d.job.id); - Rest.get() - .then(function (res) { - if(res.data.results && res.data.results.length > 0) { - goToJobResults(res.data.results[0].type); + t.selectAll(".rect") + .attr('stroke', function (d) { + if (d.job && d.job.status) { + if (d.job.status === "successful") { + return "#5cb85c"; + } else if (d.job.status === "failed" || d.job.status === "error" || d.job.status === "cancelled") { + return "#d9534f"; + } else { + return "#D7D7D7"; + } + } else { + return "#D7D7D7"; } }) - .catch(({data, status}) => { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Unable to get job: ' + status }); + .attr("class", function (d) { + let classString = d.placeholder ? "rect placeholder" : "rect"; + classString += !d.unifiedJobTemplate ? " WorkflowChart-dashedNode" : ""; + return classString; + }); + + t.selectAll(".node") + .attr("parent", function (d) { + return d.parent ? d.parent.id : null; + }) + .attr("transform", function (d) { + d.px = d.x; + d.py = d.y; + return "translate(" + d.y + "," + d.x + ")"; + }); + + t.selectAll(".WorkflowChart-nodeTypeCircle") + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + }); + + t.selectAll(".WorkflowChart-nodeTypeLetter") + .text(function (d) { + return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + }) + .style("display", function (d) { + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + }); + + t.selectAll(".WorkflowChart-nodeStatus") + .attr("class", function (d) { + + let statusClass = "WorkflowChart-nodeStatus "; + + if (d.job) { + switch (d.job.status) { + case "pending": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "waiting": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "running": + statusClass += "workflowChart-nodeStatus--running"; + break; + case "successful": + statusClass += "workflowChart-nodeStatus--success"; + break; + case "failed": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "error": + statusClass += "workflowChart-nodeStatus--failed"; + break; + case "canceled": + statusClass += "workflowChart-nodeStatus--canceled"; + break; + } + } + + return statusClass; + }) + .style("display", function (d) { + return d.job && d.job.status ? null : "none"; + }) + .transition() + .duration(0) + .attr("r", 6) + .each(function (d) { + if (d.job && d.job.status && (d.job.status === "pending" || d.job.status === "waiting" || d.job.status === "running")) { + // Pulse the circle + var circle = d3.select(this); + (function repeat() { + circle = circle.transition() + .duration(2000) + .attr("r", 6) + .transition() + .duration(2000) + .attr("r", 0) + .ease('sine') + .each("end", repeat); + })(); + } + }); + + t.selectAll(".WorkflowChart-nameText") + .attr("x", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? 20 : nodeW / 2; + }) + .attr("y", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? 10 : nodeH / 2; + }) + .attr("text-anchor", function (d) { + return (scope.mode === 'details' && d.job && d.job.status) ? "inherit" : "middle"; + }) + .text(function (d) { + return (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? wrap(d.unifiedJobTemplate.name) : ""; + }); + + t.selectAll(".WorkflowChart-detailsLink") + .style("display", function (d) { + return d.job && d.job.status && d.job.id ? null : "none"; + }); + + t.selectAll(".WorkflowChart-deletedText") + .style("display", function (d) { + return d.unifiedJobTemplate || d.placeholder ? "none" : null; + }); + + t.selectAll(".WorkflowChart-conflictText") + .style("display", function (d) { + return (d.edgeConflict && !d.placeholder) ? null : "none"; + }); + + t.selectAll(".WorkflowChart-activeNode") + .style("display", function (d) { + return d.isActiveEdit ? null : "none"; + }); + + t.selectAll(".WorkflowChart-elapsed") + .style("display", function (d) { + return (d.job && d.job.elapsed) ? null : "none"; + }); + } else if (!scope.watchDimensionsSet) { + scope.watchDimensionsSet = scope.$watch('dimensionsSet', function () { + if (scope.dimensionsSet) { + scope.watchDimensionsSet(); + scope.watchDimensionsSet = null; + update(); + } + }); + } + } + + function add_node() { + this.on("click", function (d) { + if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.addNode({ + parent: d, + betweenTwoNodes: false }); } + }); + } + + function add_node_between() { + this.on("click", function (d) { + if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.addNode({ + parent: d, + betweenTwoNodes: true + }); + } + }); + } + + function remove_node() { + this.on("click", function (d) { + if ((scope.workflowJobTemplateObj && scope.workflowJobTemplateObj.summary_fields && scope.workflowJobTemplateObj.summary_fields.user_capabilities && scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) || scope.canAddWorkflowJobTemplate) { + scope.deleteNode({ + nodeToDelete: d + }); + } + }); + } + + function edit_node() { + this.on("click", function (d) { + if (d.canEdit) { + scope.editNode({ + nodeToEdit: d + }); + } + }); + } + + function details() { + this.on("mouseover", function () { + d3.select(this).style("text-decoration", "underline"); + }); + this.on("mouseout", function () { + d3.select(this).style("text-decoration", null); + }); + this.on("click", function (d) { + + let goToJobResults = function (job_type) { + if (job_type === 'job') { + $state.go('output', { + id: d.job.id, + type: 'playbook' + }); + } else if (job_type === 'inventory_update') { + $state.go('output', { + id: d.job.id, + type: 'inventory' + }); + } else if (job_type === 'project_update') { + $state.go('output', { + id: d.job.id, + type: 'project' + }); + } + }; + + if (d.job.id) { + if (d.unifiedJobTemplate) { + goToJobResults(d.unifiedJobTemplate.unified_job_type); + } else { + // We don't have access to the unified resource and have to make + // a GET request in order to find out what type job this was + // so that we can route the user to the correct stdout view + + Rest.setUrl(GetBasePath("unified_jobs") + "?id=" + d.job.id); + Rest.get() + .then(function (res) { + if (res.data.results && res.data.results.length > 0) { + goToJobResults(res.data.results[0].type); + } + }) + .catch(({ + data, + status + }) => { + ProcessErrors(scope, data, status, null, { + hdr: 'Error!', + msg: 'Unable to get job: ' + status + }); + }); + } + } + }); + } + + scope.$watch('canAddWorkflowJobTemplate', function () { + // Redraw the graph if permissions change + if (scope.treeData) { + update(); } }); - } - scope.$watch('canAddWorkflowJobTemplate', function() { - // Redraw the graph if permissions change - if(scope.treeData) { - update(); - } - }); - - scope.$on('refreshWorkflowChart', function(){ - if(scope.treeData) { - update(); - } - }); - - scope.$on('panWorkflowChart', function(evt, params) { - manualPan(params.direction); - }); - - scope.$on('resetWorkflowChart', function(){ - resetZoomAndPan(); - }); - - scope.$on('zoomWorkflowChart', function(evt, params) { - manualZoom(params.zoom); - }); - - scope.$on('zoomToFitChart', function() { - zoomToFitChart(); - }); - - let clearWatchTreeData = scope.$watch('treeData', function(newVal) { - if(newVal) { - update(); - clearWatchTreeData(); - } - }); - - function onResize(){ - let dimensions = calcAvailableScreenSpace(); - - $('.WorkflowMaker-chart').css("width", dimensions.width); - $('.WorkflowMaker-chart').css("height", dimensions.height); - } - - function cleanUpResize() { - angular.element($window).off('resize', onResize); - } - - if(scope.mode === 'details') { - angular.element($window).on('resize', onResize); - scope.$on('$destroy', cleanUpResize); - - scope.$on('workflowDetailsResized', function(){ - $('.WorkflowMaker-chart').hide(); - $timeout(function(){ - onResize(); - $('.WorkflowMaker-chart').show(); - }); + scope.$on('refreshWorkflowChart', function () { + if (scope.treeData) { + update(); + } }); - } - else { - scope.$on('workflowMakerModalResized', function(){ + + scope.$on('panWorkflowChart', function (evt, params) { + manualPan(params.direction); + }); + + scope.$on('resetWorkflowChart', function () { + resetZoomAndPan(); + }); + + scope.$on('zoomWorkflowChart', function (evt, params) { + manualZoom(params.zoom); + }); + + scope.$on('zoomToFitChart', function () { + zoomToFitChart(); + }); + + let clearWatchTreeData = scope.$watch('treeData', function (newVal) { + if (newVal) { + update(); + clearWatchTreeData(); + } + }); + + function onResize() { let dimensions = calcAvailableScreenSpace(); $('.WorkflowMaker-chart').css("width", dimensions.width); $('.WorkflowMaker-chart').css("height", dimensions.height); - }); + } + + function cleanUpResize() { + angular.element($window).off('resize', onResize); + } + + if (scope.mode === 'details') { + angular.element($window).on('resize', onResize); + scope.$on('$destroy', cleanUpResize); + + scope.$on('workflowDetailsResized', function () { + $('.WorkflowMaker-chart').hide(); + $timeout(function () { + onResize(); + $('.WorkflowMaker-chart').show(); + }); + }); + } else { + scope.$on('workflowMakerModalResized', function () { + let dimensions = calcAvailableScreenSpace(); + + $('.WorkflowMaker-chart').css("width", dimensions.width); + $('.WorkflowMaker-chart').css("height", dimensions.height); + }); + } } - } - }; -}]; + }; + } +]; From e20d8c8e81bacde3e04c0551952fcfbc59e59f22 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Sat, 6 Oct 2018 19:41:12 -0400 Subject: [PATCH 08/15] Show workflow badge Add Workflow tags Hookup workflow details link Add parent workflow and job explanation fields Add workflow key icon to WF maker and WF results Hookup wf prompting Add wf key dropdown and hide wf info badge --- .../features/templates/templates.strings.js | 1 + awx/ui/client/legacy/styles/lists.less | 15 --- .../src/partials/job-template-details.html | 5 +- awx/ui/client/src/shared/generator-helpers.js | 7 ++ awx/ui/client/src/templates/main.js | 42 +++---- .../workflow-chart.directive.js | 92 ++++++++++++-- .../workflow-maker/workflow-maker.block.less | 11 +- .../workflow-maker.controller.js | 114 +++++++++++------- .../workflow-maker.partial.html | 6 + .../workflow-results.block.less | 10 ++ .../workflow-results.controller.js | 35 +++++- .../workflow-results.partial.html | 109 ++++++++++++----- 12 files changed, 317 insertions(+), 130 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 08f4ae8926..c22d1805f7 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -102,6 +102,7 @@ function TemplatesStrings (BaseString) { ALWAYS: t.s('Always'), PROJECT_SYNC: t.s('Project Sync'), INVENTORY_SYNC: t.s('Inventory Sync'), + WORKFLOW: t.s('Workflow'), WARNING: t.s('Warning'), TOTAL_TEMPLATES: t.s('TOTAL TEMPLATES'), ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'), diff --git a/awx/ui/client/legacy/styles/lists.less b/awx/ui/client/legacy/styles/lists.less index 28f9440438..47a3a9bf28 100644 --- a/awx/ui/client/legacy/styles/lists.less +++ b/awx/ui/client/legacy/styles/lists.less @@ -493,21 +493,6 @@ table, tbody { } } -.List-infoCell--badge { - height: 15px; - color: @default-interface-txt; - background-color: @default-list-header-bg; - border-radius: 5px; - font-size: 10px; - padding-left: 10px; - padding-right: 10px; - margin-left: 10px; - text-transform: uppercase; - font-weight: 100; - margin-top: 2.25px; - outline: none; -} - .List-actionsInner { display: flex; } diff --git a/awx/ui/client/src/partials/job-template-details.html b/awx/ui/client/src/partials/job-template-details.html index 26221941b0..c5a7410bf9 100644 --- a/awx/ui/client/src/partials/job-template-details.html +++ b/awx/ui/client/src/partials/job-template-details.html @@ -1,3 +1,4 @@ -
- INFO +
+ ?
diff --git a/awx/ui/client/src/shared/generator-helpers.js b/awx/ui/client/src/shared/generator-helpers.js index da9ede3e06..2e5a0da0e9 100644 --- a/awx/ui/client/src/shared/generator-helpers.js +++ b/awx/ui/client/src/shared/generator-helpers.js @@ -696,6 +696,13 @@ angular.module('GeneratorHelpers', [systemStatus.name]) if (options.mode !== 'lookup' && field.badgeIcon && field.badgePlacement && field.badgePlacement !== 'left') { html += Badge(field); } + + // Field Tag + if (field.tag) { + html += ` + ${field.tag} + `; + } } } diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 85e3d08f59..f468d2e193 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -409,9 +409,6 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p workflowMaker = { name: 'templates.editWorkflowJobTemplate.workflowMaker', url: '/workflow-maker', - // ncyBreadcrumb: { - // label: 'WORKFLOW MAKER' - // }, data: { formChildState: true }, @@ -468,14 +465,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - $scope.$watch('job_templates', function(){ + $scope.$watch('templates', function(){ if($scope.selectedTemplate){ - $scope.job_templates.forEach(function(row, i) { + $scope.templates.forEach(function(row, i) { if(row.id === $scope.selectedTemplate.id) { - $scope.job_templates[i].checked = 1; + $scope.templates[i].checked = 1; } else { - $scope.job_templates[i].checked = 0; + $scope.templates[i].checked = 0; } }); } @@ -484,9 +481,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope.toggle_row = function(selectedRow) { if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.job_templates.forEach(function(row, i) { + $scope.templates.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.job_templates[i].checked = 1; + $scope.templates[i].checked = 1; $scope.selection[list.iterator] = { id: row.id, name: row.name @@ -499,27 +496,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }; $scope.$watch('selectedTemplate', () => { - $scope.job_templates.forEach(function(row, i) { - if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.job_templates[i].checked = 1; + $scope.templates.forEach(function(row, i) { + if(_.has($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { + $scope.templates[i].checked = 1; } else { - $scope.job_templates[i].checked = 0; + $scope.templates[i].checked = 0; } }); }); $scope.$watch('activeTab', () => { if(!$scope.activeTab || $scope.activeTab !== "jobs") { - $scope.job_templates.forEach(function(row, i) { - $scope.job_templates[i].checked = 0; + $scope.templates.forEach(function(row, i) { + $scope.templates[i].checked = 0; }); } }); $scope.$on('clearWorkflowLists', function() { - $scope.job_templates.forEach(function(row, i) { - $scope.job_templates[i].checked = 0; + $scope.templates.forEach(function(row, i) { + $scope.templates[i].checked = 0; }); }); } @@ -699,8 +696,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p return qs.search(path, $stateParams[`${list.iterator}_search`]); } ], - WorkflowMakerJobTemplateList: ['TemplateList', - (TemplateList) => { + WorkflowMakerJobTemplateList: ['TemplateList', 'i18n', + (TemplateList, i18n) => { let list = _.cloneDeep(TemplateList); delete list.actions; delete list.fields.type; @@ -709,15 +706,18 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p delete list.fields.labels; delete list.fieldActions; list.fields.name.columnClass = "col-md-8"; + list.fields.name.tag = i18n._('WORKFLOW'); + list.fields.name.showTag = "{{template.type === 'workflow_job_template'}}"; list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; - list.iterator = 'job_template'; - list.name = 'job_templates'; + list.iterator = 'template'; + list.name = 'templates'; list.basePath = 'unified_job_templates'; list.fields.info = { ngInclude: "'/static/partials/job-template-details.html'", type: 'template', columnClass: 'col-md-3', + infoHeaderClass: 'col-md-3', label: '', nosort: true }; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 8880c86321..174e3c3f11 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -385,8 +385,13 @@ export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'G .attr("r", 10) .attr("class", "WorkflowChart-nodeTypeCircle") .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; - }); + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; + }); thisNode.append("text") .attr("y", nodeH) @@ -394,10 +399,42 @@ export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'G .attr("text-anchor", "middle") .attr("class", "WorkflowChart-nodeTypeLetter") .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; }) .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; }); thisNode.append("rect") @@ -828,15 +865,52 @@ export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'G t.selectAll(".WorkflowChart-nodeTypeCircle") .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; }); t.selectAll(".WorkflowChart-nodeTypeLetter") .text(function (d) { - return (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update")) ? "P" : (d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? "I" : ""); + let nodeTypeLetter = ""; + if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) { + switch (d.unifiedJobTemplate.type) { + case "project": + nodeTypeLetter = "P"; + break; + case "inventory_source": + nodeTypeLetter = "I"; + break; + case "workflow_job_template": + nodeTypeLetter = "W"; + break; + } + } else if (d.unifiedJobTemplate && d.unifiedJobTemplate.unified_job_type) { + switch (d.unifiedJobTemplate.unified_job_type) { + case "project_update": + nodeTypeLetter = "P"; + break; + case "inventory_update": + nodeTypeLetter = "I"; + break; + case "workflow_job": + nodeTypeLetter = "W"; + break; + } + } + return nodeTypeLetter; }) .style("display", function (d) { - return d.unifiedJobTemplate && (d.unifiedJobTemplate.type === "project" || d.unifiedJobTemplate.unified_job_type === "project_update" || d.unifiedJobTemplate.type === "inventory_source" || d.unifiedJobTemplate.unified_job_type === "inventory_update") ? null : "none"; + return d.unifiedJobTemplate && + (d.unifiedJobTemplate.type === "project" || + d.unifiedJobTemplate.unified_job_type === "project_update" || + d.unifiedJobTemplate.type === "inventory_source" || + d.unifiedJobTemplate.unified_job_type === "inventory_update" || + d.unifiedJobTemplate.type === "workflow_job_template" || + d.unifiedJobTemplate.unified_job_type === "workflow_job") ? null : "none"; }); t.selectAll(".WorkflowChart-nodeStatus") @@ -1011,6 +1085,10 @@ export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'G id: d.job.id, type: 'project' }); + } else if (job_type === 'workflow_job') { + $state.go('workflowResults', { + id: d.job.id, + }); } }; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less index a189cfae12..65f7a9c2a9 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.block.less @@ -202,23 +202,17 @@ line-height: 20px; } .WorkflowLegend-details { + align-items: center; display: flex; height: 40px; line-height: 40px; - padding-left: 20px; margin-top:10px; border: 1px solid @d7grey; border-top-left-radius: 5px; border-top-right-radius: 5px; } -.WorkflowLegend-legendItem { - display: flex; -} -.WorkflowLegend-legendItem:not(:last-child) { - padding-right: 20px; -} .WorkflowLegend-details--left { - display: flex; + display: block; flex: 1 0 auto; } .WorkflowLegend-details--right { @@ -304,6 +298,7 @@ height: 3px; width: 20px; margin: 9px 5px 9px 0px; + outline: none; } .Key-heading { font-weight: 700; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index f94c1f3422..0c8f4fbf1b 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -5,10 +5,10 @@ *************************************************/ export default ['$scope', 'WorkflowService', 'TemplatesService', - 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', + 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', 'WorkflowJobTemplateModel', 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', function ($scope, WorkflowService, TemplatesService, - ProcessErrors, CreateSelect2, $q, JobTemplate, + ProcessErrors, CreateSelect2, $q, JobTemplate, WorkflowJobTemplate, Empty, PromptService, Rest, TemplatesStrings, $timeout) { let promptWatcher, surveyQuestionWatcher, credentialsWatcher; @@ -110,7 +110,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', // Check to see if the user has provided any prompt values that are different // from the defaults in the job template - if (params.node.unifiedJobTemplate.type === "job_template" && params.node.promptData) { + if ((params.node.unifiedJobTemplate.type === "job_template" || params.node.unifiedJobTemplate.type === "workflow_job_template") && params.node.promptData) { sendableNodeData = PromptService.bundlePromptDataForSaving({ promptData: params.node.promptData, dataToSave: sendableNodeData @@ -188,7 +188,11 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', params.node.isNew = false; continueRecursing(data.data.id); - }, function ({ data, config, status }) { + }, function ({ + data, + config, + status + }) { ProcessErrors($scope, data, status, null, { hdr: $scope.strings.get('error.HEADER'), msg: $scope.strings.get('error.CALL', { @@ -339,7 +343,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', let missingPromptValue = false; if ($scope.missingSurveyValue) { missingPromptValue = true; - } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + } else if ($scope.selectedTemplate.type === 'job_template' && (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id)) { missingPromptValue = true; } $scope.promptModalMissingReqFields = missingPromptValue; @@ -481,7 +485,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.placeholderNode.unifiedJobTemplate = $scope.selectedTemplate; $scope.placeholderNode.edgeType = $scope.edgeType.value; - if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template') { + if ($scope.placeholderNode.unifiedJobTemplate.type === 'job_template' || + $scope.placeholderNode.unifiedJobTemplate.type === 'workflow_job_template') { $scope.placeholderNode.promptData = _.cloneDeep($scope.promptData); } $scope.placeholderNode.canEdit = true; @@ -498,8 +503,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', if ($scope.selectedTemplate && $scope.edgeType && $scope.edgeType.value) { $scope.nodeBeingEdited.unifiedJobTemplate = $scope.selectedTemplate; $scope.nodeBeingEdited.edgeType = $scope.edgeType.value; - - if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template') { + if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' || $scope.nodeBeingEdited.unifiedJobTemplate.type === 'workflow_job_template') { $scope.nodeBeingEdited.promptData = _.cloneDeep($scope.promptData); } @@ -591,9 +595,8 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', $scope.nodeBeingEdited.isActiveEdit = true; let finishConfiguringEdit = function () { - - let jobTemplate = new JobTemplate(); - + let templateType = $scope.nodeBeingEdited.unifiedJobTemplate.type; + let jobTemplate = templateType === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); if (!_.isEmpty($scope.nodeBeingEdited.promptData)) { $scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData); const launchConf = $scope.promptData.launchConf; @@ -615,15 +618,17 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } else { $scope.showPromptButton = true; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) { + if ($scope.nodeBeingEdited.unifiedJobTemplate.type === 'job_template' && launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) { $scope.promptModalMissingReqFields = true; } else { $scope.promptModalMissingReqFields = false; } } } else if ( - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' || - _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job' || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'workflow_job_template' ) { let promises = [jobTemplate.optionsLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id)]; @@ -674,8 +679,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', prompts.credentials.value = workflowNodeCredentials.concat(defaultCredsWithoutOverrides); - if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) { - $scope.selectedTemplateInvalid = true; + if ($scope.nodeBeingEdited.unifiedJobTemplate.unified_job_template === 'job') { + if ((!$scope.nodeBeingEdited.unifiedJobTemplate.inventory && !launchConf.ask_inventory_on_launch) || !$scope.nodeBeingEdited.unifiedJobTemplate.project) { + $scope.selectedTemplateInvalid = true; + } else { + $scope.selectedTemplateInvalid = false; + } } else { $scope.selectedTemplateInvalid = false; } @@ -774,7 +783,9 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate')) { - if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "job_template") { + + if (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "job_template" || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === "workflow_job_template") { $scope.workflowMakerFormConfig.activeTab = "jobs"; } @@ -783,6 +794,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', if ($scope.selectedTemplate.unified_job_type) { switch ($scope.selectedTemplate.unified_job_type) { case "job": + case "workflow_job": $scope.workflowMakerFormConfig.activeTab = "jobs"; break; case "project_update": @@ -795,6 +807,7 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } else if ($scope.selectedTemplate.type) { switch ($scope.selectedTemplate.type) { case "job_template": + case "workflow_job_template": $scope.workflowMakerFormConfig.activeTab = "jobs"; break; case "project": @@ -843,8 +856,9 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', // Determine whether or not we need to go out and GET this nodes unified job template // in order to determine whether or not prompt fields are needed - - if (!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited && $scope.nodeBeingEdited.unifiedJobTemplate && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type && $scope.nodeBeingEdited.unifiedJobTemplate.unified_job_type === 'job') { + if (!$scope.nodeBeingEdited.isNew && !$scope.nodeBeingEdited.edited && + (_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job' || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'workflow_job')) { // This is a node that we got back from the api with an incomplete // unified job template so we're going to pull down the whole object @@ -852,15 +866,19 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', .then(function (data) { $scope.nodeBeingEdited.unifiedJobTemplate = _.clone(data.data.results[0]); finishConfiguringEdit(); - }, function ({ data, status, config }) { - ProcessErrors($scope, data, status, null, { - hdr: $scope.strings.get('error.HEADER'), - msg: $scope.strings.get('error.CALL', { - path: `${config.url}`, - action: `${config.method}`, - status - }) - }); + }, function ({ + data, + status, + config + }) { + ProcessErrors($scope, data, status, null, { + hdr: $scope.strings.get('error.HEADER'), + msg: $scope.strings.get('error.CALL', { + path: `${config.url}`, + action: `${config.method}`, + status + }) + }); }); } else { finishConfiguringEdit(); @@ -982,24 +1000,24 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } $scope.promptData = null; - - if (selectedTemplate.type === "job_template") { - let jobTemplate = new JobTemplate(); + if (selectedTemplate.type === "job_template" || selectedTemplate.type === "workflow_job_template") { + let jobTemplate = selectedTemplate.type === "workflow_job_template" ? new WorkflowJobTemplate() : new JobTemplate(); $q.all([jobTemplate.optionsLaunch(selectedTemplate.id), jobTemplate.getLaunch(selectedTemplate.id)]) .then((responses) => { let launchConf = responses[1].data; + if (selectedTemplate.type === 'job_template') { + if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { + $scope.selectedTemplateInvalid = true; + } else { + $scope.selectedTemplateInvalid = false; + } - if ((!selectedTemplate.inventory && !launchConf.ask_inventory_on_launch) || !selectedTemplate.project) { - $scope.selectedTemplateInvalid = true; - } else { - $scope.selectedTemplateInvalid = false; - } - - if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { - $scope.credentialRequiresPassword = true; - } else { - $scope.credentialRequiresPassword = false; + if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { + $scope.credentialRequiresPassword = true; + } else { + $scope.credentialRequiresPassword = false; + } } $scope.selectedTemplate = angular.copy(selectedTemplate); @@ -1021,8 +1039,12 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', } else { $scope.showPromptButton = true; - if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { - $scope.promptModalMissingReqFields = true; + if (selectedTemplate.type === 'job_template') { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { + $scope.promptModalMissingReqFields = true; + } else { + $scope.promptModalMissingReqFields = false; + } } else { $scope.promptModalMissingReqFields = false; } @@ -1156,7 +1178,11 @@ export default ['$scope', 'WorkflowService', 'TemplatesService', // This is the last page buildTreeFromNodes(); } - }, function ({ data, status, config }) { + }, function ({ + data, + status, + config + }) { ProcessErrors($scope, data, status, null, { hdr: $scope.strings.get('error.HEADER'), msg: $scope.strings.get('error.CALL', { diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 3f3fb5c14f..e92cc0e9bf 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -62,6 +62,10 @@
I

{{strings.get('workflow_maker.INVENTORY_SYNC')}}

+
  • +
    W
    +

    {{strings.get('workflow_maker.WORKFLOW')}}

    +
  • !

    {{strings.get('workflow_maker.WARNING')}}

    @@ -95,7 +99,9 @@
  • diff --git a/awx/ui/client/src/workflow-results/workflow-results.block.less b/awx/ui/client/src/workflow-results/workflow-results.block.less index d2af14d1f0..4e1bd89088 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.block.less +++ b/awx/ui/client/src/workflow-results/workflow-results.block.less @@ -141,3 +141,13 @@ .WorkflowResults-extraVarsLabel { font-size:14px!important; } + +.WorkflowResults-seeMoreLess { + color: #337AB7; + margin: 4px 0px; + text-transform: uppercase; + padding: 2px 0px; + cursor: pointer; + border-radius: 5px; + font-size: 11px; +} diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index fe9d42ebcb..ec4e804714 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -1,9 +1,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', 'jobLabels', 'workflowNodes', '$scope', 'ParseTypeChange', 'ParseVariableString', 'WorkflowService', 'count', '$state', 'i18n', - 'moment', function(workflowData, workflowResultsService, + 'moment', '$filter', function(workflowData, workflowResultsService, workflowDataOptions, jobLabels, workflowNodes, $scope, ParseTypeChange, - ParseVariableString, WorkflowService, count, $state, i18n, moment) { + ParseVariableString, WorkflowService, count, $state, i18n, moment, $filter) { var runTimeElapsedTimer = null; var getLinks = function() { @@ -50,13 +50,17 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', STARTED: i18n._('Started'), FINISHED: i18n._('Finished'), LABELS: i18n._('Labels'), + STATUS: i18n._('Status'), SLICE_TEMPLATE: i18n._('Slice Job Template'), - STATUS: i18n._('Status') + JOB_EXPLANATION: i18n._('Explanation'), + SOURCE_WORKFLOW_JOB: i18n._('Parent Workflow') }, details: { HEADER: i18n._('DETAILS'), NOT_FINISHED: i18n._('Not Finished'), NOT_STARTED: i18n._('Not Started'), + SHOW_LESS: i18n._('Show Less'), + SHOW_MORE: i18n._('Show More'), }, results: { TOTAL_JOBS: i18n._('Total Jobs'), @@ -68,6 +72,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', ALWAYS: i18n._('Always'), PROJECT_SYNC: i18n._('Project Sync'), INVENTORY_SYNC: i18n._('Inventory Sync'), + WORKFLOW: i18n._('Workflow'), KEY: i18n._('KEY'), } }; @@ -100,6 +105,9 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', $scope.labels = jobLabels; $scope.count = count.val; $scope.showManualControls = false; + $scope.showKey = false; + $scope.toggleKey = () => $scope.showKey = !$scope.showKey; + $scope.keyClassList = `{ 'Key-menuIcon--active': showKey }`; // Start elapsed time updater for job known to be running if ($scope.workflow.started !== null && $scope.workflow.status === 'running') { @@ -116,6 +124,27 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', $scope.slice_job_template_link = `/#/templates/job_template/${$scope.workflow.summary_fields.job_template.id}`; } + if (_.get(workflowData, 'summary_fields.source_workflow_job.id')) { + $scope.source_workflow_job_link = `/#/workflows/${workflowData.summary_fields.source_workflow_job.id}`; + } + + if (workflowData.job_explanation) { + const limit = 150; + const more = workflowData.job_explanation; + const less = $filter('limitTo')(more, limit); + const showMore = false; + const hasMoreToShow = more.length > limit; + + const job_explanation = { + more: more, + less: less, + showMore: showMore, + hasMoreToShow: hasMoreToShow + }; + + $scope.job_explanation = job_explanation; + } + // turn related api browser routes into front end routes getLinks(); diff --git a/awx/ui/client/src/workflow-results/workflow-results.partial.html b/awx/ui/client/src/workflow-results/workflow-results.partial.html index 474fac1d6e..b17a277074 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.partial.html +++ b/awx/ui/client/src/workflow-results/workflow-results.partial.html @@ -75,6 +75,33 @@
    + +
    + + +
    + {{ job_explanation.less }} + ... + + {{ strings.details.SHOW_MORE }} + +
    + +
    + {{ job_explanation.more }} + + {{ strings.details.SHOW_LESS }} + +
    +
    +
    @@ -144,18 +171,31 @@
    - + +
    + + +
    + +
    -
    @@ -268,27 +308,36 @@
    -
    {{ strings.legend.KEY }}:
    -
    -
    -
    {{ strings.legend.ON_SUCCESS }}
    -
    -
    -
    -
    {{ strings.legend.ON_FAIL }}
    -
    -
    -
    -
    {{ strings.legend.ALWAYS }}
    -
    -
    -
    P
    -
    {{ strings.legend.PROJECT_SYNC }}
    -
    -
    -
    I
    -
    {{ strings.legend.INVENTORY_SYNC }}
    -
    + +
      +
    • +

      {{strings.legend.KEY}}

      +
    • +
    • +
      +

      {{strings.legend.ON_SUCCESS}}

      +
    • +
    • +
      +

      {{strings.legend.ON_FAILURE}}

      +
    • +
    • +
      +

      {{strings.legend.ALWAYS}}

      +
    • +
    • +
      P
      +

      {{strings.legend.PROJECT_SYNC}}

      +
    • +
    • +
      I
      +

      {{strings.legend.INVENTORY_SYNC}}

      +
    • +
    • +
      W
      +

      {{strings.legend.WORKFLOW}}

      +
    • +
    From d068481aecbd2e3339157bf76cc9fee6c32c1d85 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 2 Nov 2018 09:00:03 -0400 Subject: [PATCH 09/15] link workflow job node based on job type, not UJT type --- awx/api/serializers.py | 2 +- .../workflow-chart.directive.js | 25 +------------------ 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index dad4437bd7..4abb60d9a5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -104,7 +104,7 @@ SUMMARIZABLE_FK_FIELDS = { 'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',), 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), 'vault_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), - 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed'), + 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed', 'type'), 'job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job': DEFAULT_SUMMARY_FIELDS, diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 174e3c3f11..aaa34fad04 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -1093,30 +1093,7 @@ export default ['$state', 'moment', '$timeout', '$window', '$filter', 'Rest', 'G }; if (d.job.id) { - if (d.unifiedJobTemplate) { - goToJobResults(d.unifiedJobTemplate.unified_job_type); - } else { - // We don't have access to the unified resource and have to make - // a GET request in order to find out what type job this was - // so that we can route the user to the correct stdout view - - Rest.setUrl(GetBasePath("unified_jobs") + "?id=" + d.job.id); - Rest.get() - .then(function (res) { - if (res.data.results && res.data.results.length > 0) { - goToJobResults(res.data.results[0].type); - } - }) - .catch(({ - data, - status - }) => { - ProcessErrors(scope, data, status, null, { - hdr: 'Error!', - msg: 'Unable to get job: ' + status - }); - }); - } + goToJobResults(d.job.type); } }); } From edda4bb265205dca3fba63db836bc4f242325c7d Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 2 Nov 2018 12:43:22 -0400 Subject: [PATCH 10/15] Address PR review --- awx/ui/client/src/partials/job-template-details.html | 6 +++--- .../src/workflow-results/workflow-results.controller.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/src/partials/job-template-details.html b/awx/ui/client/src/partials/job-template-details.html index c5a7410bf9..38dd370da7 100644 --- a/awx/ui/client/src/partials/job-template-details.html +++ b/awx/ui/client/src/partials/job-template-details.html @@ -1,4 +1,4 @@ -
    - ? +
    + ?
    diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index ec4e804714..81c62947ff 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -68,7 +68,7 @@ export default ['workflowData', 'workflowResultsService', 'workflowDataOptions', }, legend: { ON_SUCCESS: i18n._('On Success'), - ON_FAIL: i18n._('On Fail'), + ON_FAILURE: i18n._('On Failure'), ALWAYS: i18n._('Always'), PROJECT_SYNC: i18n._('Project Sync'), INVENTORY_SYNC: i18n._('Inventory Sync'), From 2ae8583a869fbf4bfb61098885cb87ad560db46e Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 2 Nov 2018 17:31:47 -0400 Subject: [PATCH 11/15] Fix Firefox labels bug --- awx/ui/client/lib/components/list/_index.less | 1 + awx/ui/client/lib/theme/_variables.less | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index c5b64d4eed..1d8cd87867 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -190,6 +190,7 @@ padding: @at-padding-list-row-item-tag; line-height: @at-line-height-list-row-item-tag; word-break: keep-all; + display: inline-flex; } .at-RowItem-tag--primary { diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index baab97f85b..f5a5add310 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -323,7 +323,7 @@ @at-highlight-left-border-margin-makeup: -5px; @at-z-index-nav: 1040; @at-z-index-side-nav: 1030; -@at-line-height-list-row-item-tag: 22px; +@at-line-height-list-row-item-tag: 14px; // 6. Breakpoints --------------------------------------------------------------------------------- From a3d5705cea85521d2a3bc6884c6c8b309fc489d7 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 5 Nov 2018 14:00:22 -0500 Subject: [PATCH 12/15] Fix bug with workflow maker templates pagination and smart search --- .../src/partials/job-template-details.html | 6 +- awx/ui/client/src/templates/main.js | 107 +++++++++--------- 2 files changed, 59 insertions(+), 54 deletions(-) diff --git a/awx/ui/client/src/partials/job-template-details.html b/awx/ui/client/src/partials/job-template-details.html index 38dd370da7..bbce5ece91 100644 --- a/awx/ui/client/src/partials/job-template-details.html +++ b/awx/ui/client/src/partials/job-template-details.html @@ -1,4 +1,4 @@ -
    - ? +
    + ?
    diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index f468d2e193..11282a3d29 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -413,28 +413,29 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p formChildState: true }, params: { - job_template_search: { + wf_maker_template_search: { value: { - page_size: '5', order_by: 'name', - role_level: 'execute_role' + page_size: '10', + role_level: 'execute_role', + type: 'workflow_job_template,job_template' }, squash: false, dynamic: true }, - project_search: { + wf_maker_project_search: { value: { - page_size: '5', - order_by: 'name' + order_by: 'name', + page_size: '10' }, squash: true, dynamic: true }, - inventory_source_search: { + wf_maker_inventory_source_search: { value: { - page_size: '5', not__source: '', - order_by: 'name' + order_by: 'name', + page_size: '10' }, squash: true, dynamic: true @@ -465,14 +466,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - $scope.$watch('templates', function(){ + $scope.$watch('wf_maker_templates', function(){ if($scope.selectedTemplate){ - $scope.templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach(function(row, i) { if(row.id === $scope.selectedTemplate.id) { - $scope.templates[i].checked = 1; + $scope.wf_maker_templates[i].checked = 1; } else { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates[i].checked = 0; } }); } @@ -481,9 +482,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope.toggle_row = function(selectedRow) { if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.templates[i].checked = 1; + $scope.wf_maker_templates[i].checked = 1; $scope.selection[list.iterator] = { id: row.id, name: row.name @@ -496,27 +497,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }; $scope.$watch('selectedTemplate', () => { - $scope.templates.forEach(function(row, i) { + $scope.wf_maker_templates.forEach(function(row, i) { if(_.has($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.templates[i].checked = 1; + $scope.wf_maker_templates[i].checked = 1; } else { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates[i].checked = 0; } }); }); $scope.$watch('activeTab', () => { if(!$scope.activeTab || $scope.activeTab !== "jobs") { - $scope.templates.forEach(function(row, i) { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates.forEach(function(row, i) { + $scope.wf_maker_templates[i].checked = 0; }); } }); $scope.$on('clearWorkflowLists', function() { - $scope.templates.forEach(function(row, i) { - $scope.templates[i].checked = 0; + $scope.wf_maker_templates.forEach(function(row, i) { + $scope.wf_maker_templates[i].checked = 0; }); }); } @@ -542,14 +543,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - $scope.$watch('workflow_inventory_sources', function(){ + $scope.$watch('wf_maker_inventory_sources', function(){ if($scope.selectedTemplate){ - $scope.workflow_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach(function(row, i) { if(row.id === $scope.selectedTemplate.id) { - $scope.workflow_inventory_sources[i].checked = 1; + $scope.wf_maker_inventory_sources[i].checked = 1; } else { - $scope.workflow_inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources[i].checked = 0; } }); } @@ -558,9 +559,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope.toggle_row = function(selectedRow) { if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.workflow_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.workflow_inventory_sources[i].checked = 1; + $scope.wf_maker_inventory_sources[i].checked = 1; $scope.selection[list.iterator] = { id: row.id, name: row.name @@ -573,27 +574,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }; $scope.$watch('selectedTemplate', () => { - $scope.workflow_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources.forEach(function(row, i) { if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.workflow_inventory_sources[i].checked = 1; + $scope.wf_maker_inventory_sources[i].checked = 1; } else { - $scope.workflow_inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources[i].checked = 0; } }); }); $scope.$watch('activeTab', () => { if(!$scope.activeTab || $scope.activeTab !== "inventory_sync") { - $scope.workflow_inventory_sources.forEach(function(row, i) { - $scope.workflow_inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources[i].checked = 0; }); } }); $scope.$on('clearWorkflowLists', function() { - $scope.workflow_inventory_sources.forEach(function(row, i) { - $scope.workflow_inventory_sources[i].checked = 0; + $scope.wf_maker_inventory_sources.forEach(function(row, i) { + $scope.wf_maker_inventory_sources[i].checked = 0; }); }); } @@ -619,14 +620,14 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - $scope.$watch('projects', function(){ + $scope.$watch('wf_maker_projects', function(){ if($scope.selectedTemplate){ - $scope.projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach(function(row, i) { if(row.id === $scope.selectedTemplate.id) { - $scope.projects[i].checked = 1; + $scope.wf_maker_projects[i].checked = 1; } else { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects[i].checked = 0; } }); } @@ -635,9 +636,9 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p $scope.toggle_row = function(selectedRow) { if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { - $scope.projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach(function(row, i) { if (row.id === selectedRow.id) { - $scope.projects[i].checked = 1; + $scope.wf_maker_projects[i].checked = 1; $scope.selection[list.iterator] = { id: row.id, name: row.name @@ -650,27 +651,27 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p }; $scope.$watch('selectedTemplate', () => { - $scope.projects.forEach(function(row, i) { + $scope.wf_maker_projects.forEach(function(row, i) { if(_.hasIn($scope, 'selectedTemplate.id') && row.id === $scope.selectedTemplate.id) { - $scope.projects[i].checked = 1; + $scope.wf_maker_projects[i].checked = 1; } else { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects[i].checked = 0; } }); }); $scope.$watch('activeTab', () => { if(!$scope.activeTab || $scope.activeTab !== "project_sync") { - $scope.projects.forEach(function(row, i) { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects.forEach(function(row, i) { + $scope.wf_maker_projects[i].checked = 0; }); } }); $scope.$on('clearWorkflowLists', function() { - $scope.projects.forEach(function(row, i) { - $scope.projects[i].checked = 0; + $scope.wf_maker_projects.forEach(function(row, i) { + $scope.wf_maker_projects[i].checked = 0; }); }); } @@ -705,13 +706,13 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p delete list.fields.smart_status; delete list.fields.labels; delete list.fieldActions; + list.name = 'wf_maker_templates'; + list.iterator = 'wf_maker_template'; list.fields.name.columnClass = "col-md-8"; list.fields.name.tag = i18n._('WORKFLOW'); - list.fields.name.showTag = "{{template.type === 'workflow_job_template'}}"; + list.fields.name.showTag = "{{wf_maker_template.type === 'workflow_job_template'}}"; list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; - list.iterator = 'template'; - list.name = 'templates'; list.basePath = 'unified_job_templates'; list.fields.info = { ngInclude: "'/static/partials/job-template-details.html'", @@ -733,6 +734,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p delete list.fields.status; delete list.fields.scm_type; delete list.fields.last_updated; + list.name = 'wf_maker_projects'; + list.iterator = 'wf_maker_project'; list.fields.name.columnClass = "col-md-11"; list.maxVisiblePages = 5; list.searchBarFullWidth = true; @@ -745,6 +748,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p WorkflowInventorySourcesList: ['InventorySourcesList', (InventorySourcesList) => { let list = _.cloneDeep(InventorySourcesList); + list.name = 'wf_maker_inventory_sources'; + list.iterator = 'wf_maker_inventory_source'; list.maxVisiblePages = 5; list.searchBarFullWidth = true; list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; From 0783d86c6c2fd98bf08e1ae677954403511426b0 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 5 Nov 2018 14:48:34 -0500 Subject: [PATCH 13/15] adjust recursion error text --- awx/main/models/workflow.py | 2 +- awx/main/scheduler/task_manager.py | 11 ++++++----- awx/main/tests/functional/models/test_workflow.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 60a5f151f2..1f1d776bd4 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -527,7 +527,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio 'this is not normal and suggests task manager degeneracy.') break wj_ids.add(wj.pk) - ancestors.append(wj.workflow_job_template_id) + ancestors.append(wj.workflow_job_template) wj = wj.get_workflow_job() return ancestors diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 4936ff74e0..0d7c528566 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -124,17 +124,18 @@ class TaskManager(): can_start = True if isinstance(spawn_node.unified_job_template, WorkflowJobTemplate): workflow_ancestors = job.get_ancestor_workflows() - if spawn_node.unified_job_template.id in set(workflow_ancestors): + if spawn_node.unified_job_template in set(workflow_ancestors): can_start = False logger.info('Refusing to start recursive workflow-in-workflow id={}, wfjt={}, ancestors={}'.format( - job.id, spawn_node.unified_job_template.id, workflow_ancestors)) + job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors])) + display_list = [spawn_node.unified_job_template] + workflow_ancestors job.job_explanation = _( "Workflow Job spawned from workflow could not start because it " - "would result in recursion (template spawn order {})" - ).format([spawn_node.unified_job_template.id] + workflow_ancestors) + "would result in recursion (spawn order, most recent first: {})" + ).format(six.text_type(', ').join([six.text_type('<{}>').format(tmp) for tmp in display_list])) else: logger.debug('Starting workflow-in-workflow id={}, wfjt={}, ancestors={}'.format( - job.id, spawn_node.unified_job_template.id, workflow_ancestors)) + job.id, spawn_node.unified_job_template.pk, [wa.pk for wa in workflow_ancestors])) if not job._resources_sufficient_for_launch(): can_start = False job.job_explanation = _("Job spawned from workflow could not start because it " diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index 5b57326242..d7d03a53bb 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -249,7 +249,7 @@ def test_workflow_ancestors(organization): job=parent_job ) # ancestors method gives a list of WFJT ids - assert child_job.get_ancestor_workflows() == [parent.pk, grandparent.pk] + assert child_job.get_ancestor_workflows() == [parent, grandparent] @pytest.mark.django_db From 1bed5d4af216e34d7dc5ba0222bd7e9fe3ace705 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 6 Nov 2018 15:08:43 -0500 Subject: [PATCH 14/15] avoid nested on_commit use --- awx/main/scheduler/task_manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 0d7c528566..b527610a26 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -152,7 +152,7 @@ class TaskManager(): if not can_start: job.status = 'failed' job.save(update_fields=['status', 'job_explanation']) - connection.on_commit(lambda: job.websocket_emit_status('failed')) + job.websocket_emit_status('failed') # TODO: should we emit a status on the socket here similar to tasks.py awx_periodic_scheduler() ? #emit_websocket_notification('/socket.io/jobs', '', dict(id=)) @@ -169,7 +169,7 @@ class TaskManager(): workflow_job.status = 'canceled' workflow_job.start_args = '' # blank field to remove encrypted passwords workflow_job.save(update_fields=['status', 'start_args']) - connection.on_commit(lambda: workflow_job.websocket_emit_status(workflow_job.status)) + workflow_job.websocket_emit_status(workflow_job.status) else: is_done, has_failed = dag.is_workflow_done() if not is_done: @@ -181,7 +181,7 @@ class TaskManager(): workflow_job.status = new_status workflow_job.start_args = '' # blank field to remove encrypted passwords workflow_job.save(update_fields=['status', 'start_args']) - connection.on_commit(lambda: workflow_job.websocket_emit_status(workflow_job.status)) + workflow_job.websocket_emit_status(workflow_job.status) return result def get_dependent_jobs_for_inv_and_proj_update(self, job_obj): @@ -247,7 +247,6 @@ class TaskManager(): self.consume_capacity(task, rampart_group.name) def post_commit(): - task.websocket_emit_status(task.status) if task.status != 'failed' and type(task) is not WorkflowJob: task_cls = task._get_task_class() task_cls.apply_async( @@ -266,6 +265,7 @@ class TaskManager(): }], ) + task.websocket_emit_status(task.status) # adds to on_commit connection.on_commit(post_commit) def process_running_tasks(self, running_tasks): From ebc3dbe7b6dec30f4a46e44c1d733b7c8dd2b562 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 6 Nov 2018 22:43:44 -0500 Subject: [PATCH 15/15] Add source WF label to job details template * Change label from "Parent WF" to "Source WF" * Fix WF job result Firefox responsive style bugs --- awx/ui/client/features/output/_index.less | 3 ++- awx/ui/client/features/output/details.component.js | 4 +++- awx/ui/client/features/output/details.partial.html | 13 +++++++++++++ awx/ui/client/features/output/output.strings.js | 1 + .../client/src/bread-crumb/bread-crumb.block.less | 1 - .../workflow-chart/workflow-chart.directive.js | 3 --- .../workflow-maker/workflow-maker.block.less | 3 ++- .../workflow-results/workflow-results.block.less | 8 +++++++- .../workflow-results/workflow-results.controller.js | 3 ++- .../workflow-results/workflow-results.partial.html | 6 ++++-- 10 files changed, 34 insertions(+), 11 deletions(-) diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index b6110eb773..4491a1eb45 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -334,7 +334,7 @@ .JobResults-container { display: grid; grid-gap: 20px; - grid-template-columns: minmax(300px, 1fr) minmax(500px, 2fr); + grid-template-columns: minmax(400px, 1fr) minmax(500px, 2fr); grid-template-rows: minmax(500px, ~"calc(100vh - 130px)"); .at-Panel { @@ -457,5 +457,6 @@ .JobResults-container { display: flex; flex-direction: column; + min-width: 400px; } } diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 5d0ef52338..2b9cb2a931 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -120,10 +120,12 @@ function getSourceWorkflowJobDetails () { return null; } + const label = strings.get('labels.SOURCE_WORKFLOW_JOB'); + const value = sourceWorkflowJob.name; const link = `/#/workflows/${sourceWorkflowJob.id}`; const tooltip = strings.get('tooltips.SOURCE_WORKFLOW_JOB'); - return { link, tooltip }; + return { label, value, link, tooltip }; } function getSliceJobDetails () { diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 051309f4ac..9e42ad9c5c 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -281,6 +281,19 @@
    + +
    + + +
    + +
    - +