ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js (235 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import template from "./workflow-steps.template.html"; import angular from "angular"; const MODULE_NAME = 'inspector.workflow-steps'; angular.module(MODULE_NAME, []) .directive('workflowSteps', workflowStepsDirective); export default MODULE_NAME; export function workflowStepsDirective() { return { template: template, restrict: 'E', scope: { workflow: '=', task: '=?', nested: '=?', }, controller: ['$sce', '$timeout', '$scope', '$element', controller], controllerAs: 'vm', }; function controller($sce, $timeout, $scope, $element) { let vm = this; vm.stringify = stringify; $scope.workflowId = $scope.workflow.data.workflowId; vm.getWorkflowStepsClasses = () => { const c = []; c.push('workflow-status-'+$scope.workflow.data.status); if ($scope.workflow.data.status && $scope.workflow.data.status.startsWith('ERROR')) { c.push('workflow-error'); } return c; } $scope.expandStates = {}; if ($scope.workflow.tag && !_.isNil($scope.workflow.tag.stepIndex)) { $scope.expandStates[$scope.workflow.tag.stepIndex] = true; } vm.onSizeChange = () => $timeout(()=>recompute($scope, $element)); $scope.$watch('workflow', vm.onSizeChange); $scope.$watch(() => $element[0].offsetHeight, (newVal, oldVal) => { if (oldVal!=newVal) vm.onSizeChange(); }); vm.onSizeChange(); } function recompute($scope, $element) { let svg = $element[0].querySelector('#workflow-step-arrows.workflow-'+$scope.workflowId); let steps = $element[0].querySelectorAll('.workflow-'+$scope.workflowId+'.workflow-step'); let arrows = makeArrows($scope.workflow, steps, { width: $scope.nested ? 32 : 56 }); svg.innerHTML = arrows.join('\n'); } } function makeArrows(workflow, steps, options) { const sectionWidth = ((options || {}).width) || 56; workflow = workflow || {}; workflow.data = workflow.data || {}; let [stepsPrev,stepsNext] = getWorkflowStepsPrevNext(workflow); const arrows = []; const strokeWidth = 1.5; const arrowheadLength = 6; const arrowheadWidth = arrowheadLength/3/strokeWidth; const defs = []; defs.push('<marker id="arrowhead" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon fill="#000" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><'); defs.push('<marker id="arrowhead-gray" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-future" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><'); defs.push('<marker id="arrowhead-red" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-failed" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><'); if (steps) { let gradientCount = 0; function arrowSvg(y1, y2, opts) { var start = y1==='start/end'; var end = y2==='start/end'; if (y1==null || y2==null || (start&&end)) { // ignore if out of bounds return ""; } if (!opts) opts = {}; const color = opts.class ? '' : opts.color || (opts.colorEnd && opts.colorEnd==opts.colorStart ? opts.colorEnd : '#000'); const rightFarEdge = sectionWidth; const rightArrowheadStart = rightFarEdge - arrowheadLength; const leftFarEdge = 10; const leftActive = rightArrowheadStart + (leftFarEdge - rightArrowheadStart) * (opts.width || 1); const curveX = opts.curveX || 1; const curveY = opts.curveY || 1; // const controlPointRightFarEdge = rightFarEdge + (leftActive - rightFarEdge) * curveX; const controlPointRightArrowheadStart = rightArrowheadStart + (leftActive - rightArrowheadStart) * curveX; // average of above two, to see which works best // const controlPointRightIntermediate = (rightFarEdge+rightArrowheadStart)/2 + (leftActive - (rightFarEdge+rightArrowheadStart)/2) * curveX; // const controlPointRightExaggerated = rightArrowheadStart + (leftActive - rightFarEdge) * curveX; const controlPointStart = controlPointRightArrowheadStart; const controlPointEnd = controlPointRightArrowheadStart; const strokeConstant = color ? 'stroke="'+color+'"' : '' let standard = 'stroke-width="'+(opts.lineWidth || strokeWidth)+'" '+ 'fill="transparent" '+ '/>'; if (opts.class) standard = 'class="'+opts.class+'" '+standard; if (!opts.hideArrowhead) standard = 'marker-end="url(#'+(opts.arrowheadId || 'arrowhead')+')" ' +standard; if (opts.dashLength) standard = 'stroke-dasharray="'+opts.dashLength+'" '+standard; if (start) { return '<path d="M ' + leftFarEdge + ' ' + y2 + ' L ' + rightArrowheadStart + ' ' + y2 + '" '+ strokeConstant+' '+standard; } if (end) { return '<path d="M ' + rightFarEdge + ' ' + y1 + ' L ' + (leftFarEdge+arrowheadLength) + ' ' + y1 + '" '+ strokeConstant+' '+standard; } const yMCH = ((y2 - y1) / 2) * curveY; const yM = (y1 + y2) / 2; if (!opts.colorEnd || opts.colorEnd==opts.colorStart || y2==y1) { standard = strokeConstant + ' ' + standard; } else { const gradientId = 'gradient'+(gradientCount++); const gradY = y2>=y1 ? 'y2="1"' : 'y1="1"'; defs.push('<linearGradient id="'+gradientId+'" x2="0" '+gradY+'><stop offset="0" stop-color="'+opts.colorStart+'"/><stop offset="1" stop-color="'+opts.colorEnd+'"/></linearGradient>'); standard = 'stroke="url(#'+gradientId+')" ' + standard; } const result = '<path d="M ' + rightFarEdge + ' ' + y1 + // ' L ' + r0 + ' ' + y1 + ' ' + ' C ' + controlPointStart + ' ' + y1 + ', ' + leftActive + ' ' + (yM - yMCH) + ', ' + leftActive + ' ' + yM + ' ' + ' S ' + controlPointEnd + ' ' + y2 + ', ' + rightArrowheadStart + ' ' + y2 + '" '+standard; return result; } function stepY(n) { if (n==-1) return 'start/end'; if (!steps || n<0 || n>=steps.length || _.isNil(n)) { console.log("workflow arrow bounds error", steps, n); return null; } return steps[n].offsetTop + steps[n].offsetHeight / 2; } function arrowStep(n1, n2, opts) { let s1 = stepY(n1); let s2 = stepY(n2); const deltaForArrowMax = 6; const deltaForArrowTarget = 0.125; if (typeof s1 === "number") s1 += Math.min(steps[n1].offsetHeight * deltaForArrowTarget, deltaForArrowMax); if (typeof s2 === "number") s2 -= Math.min(steps[n2].offsetHeight * deltaForArrowTarget, deltaForArrowMax); return arrowSvg(s1, s2, opts); } let jumpSizes = {1: true}; function arrowStep2(prev, i, opts) { let curveX = 0.5; let curveY = 0.75; let width = 0.5; if (prev==-1 || i==-1) { // curve values don't matter for start/end } else if (prev==i) { width = 0.15; curveX = 0.1; curveY = 0.75; } else { let rank = jumpSizes.indexOf(''+Math.abs(prev-i)); if (rank<0) { console.log("Missing workflow link: ", prev, i); rank = 0; } if (prev > i) rank = rank + 0.5; width = 0.2 + 0.6 * (rank + 0.5) / (jumpSizes.length + 0.5); curveX = 0.8 + 0.2*width; curveY = 0.8 + 0.2*width; // higher values (above) look nicer, but make disambiguation of complex paths harder // curveX = 0.5 + 0.3*width; // curveY = 0.4 + 0.4*width; } return arrowStep(prev, i, {hideArrowhead: prev==i, width, curveX, curveY, ...opts}); } function colorFor(step, references) { if (!references) return 'red'; const i = references.indexOf(step); if (i==-3) return 'red'; // skew quadratically for lightness const skewTowards1 = x => (1 - (1-x)*(1-x)); let gray = Math.round(240 * skewTowards1(i / references.length) ); return 'rgb('+gray+','+gray+','+gray+')'; } let arrowSpecs = {}; function recordTransition(from, to, opts) { if (to!=-1 && from!=-1 && to!=from) { jumpSizes[Math.abs(from-to)] = true; } if (to<0) to=-1; // in record, -2 means end, -3 means error; here -1 means end because nothing should go to -1 if (arrowSpecs[[from,to]]) { // prefer earlier additions (real steps) over theoretical ones } else { arrowSpecs[[from, to]] = {from, to, ...(opts || {})}; } } for (var i = -3; i < steps.length; i++) { const prevsHere = stepsPrev[i]; if (prevsHere && prevsHere.length) { prevsHere.forEach(prev => { // last in list has higher z-order; this ensures within each prevStep we preserve order, // so inbound arrows are correct. currently we also prefer earlier steps, which isn't quite right for outbound arrows; // ideally we'd reconstruct the flow order, but that's a bit more work than we want to do just now. // so insertion point is always 0. (header items added at end so we don't need to include those here.) recordTransition(prev, i, { insertionPoint: 0, visited: true, colorStart: colorFor(i, stepsNext[prev]), colorEnd: colorFor(prev, prevsHere) }); }); } } // now make pale arrows for the default flow var indexOfId = {}; for (var i = 0; i < steps.length; i++) { const s = workflow.data.stepsDefinition[i]; if (!s) console.log("Missing step", i, workflow.data, steps); if (s.id) indexOfId[s.id] = i; } function isStepType(step, type) { if (!step) return false; if (step.type) return step.type == type; let s = step.startsWith ? step : step.s || step.shorthand || step.userSuppliedShorthand; if (s) return s == type || s.startsWith(type); return false; } for (var i = 0; i < steps.length; i++) { const s = workflow.data.stepsDefinition[i]; let opts = { insertionPoint: 0 }; // errors shown elsewhere // if (workflow.data.currentStepIndex === i && workflow.data.status && workflow.data.status.startsWith('ERROR')) { // recordTransition(i, -2, { ...opts, class: 'arrow-failed', arrowheadId: 'arrowhead-red' }); // } opts = { ...opts, class: 'arrow-future', arrowheadId: 'arrowhead-gray', dashLength: 8 }; let next = null; if (s.next) { if (s.next.toLowerCase()=='end') next = -1; else if (indexOfId[s.next]) next = indexOfId[s.next]; } if (isStepType(s, 'return')) next = -1; if (next!=null) { // special next per step recordTransition(i, next, opts); if (!s.condition) continue; } // if nothing special, or if was conditional, then go to next step // (only go forward 1, even if it is conditional, otherwise too many arrows) next = i+1; if (i + 1 >= steps.length) next = -1; recordTransition(i, next, opts); } jumpSizes = Object.keys(jumpSizes).sort(); // insert arrows Object.values(arrowSpecs).forEach(arrowSpec => arrows.splice(arrowSpec.insertionPoint || 0, 0, arrowStep2(arrowSpec.from, arrowSpec.to, arrowSpec)) ); // then defs at start arrows.splice(0, 0, '<defs>'+defs.join('')+'</defs>'); } return arrows; } function getWorkflowStepsPrevNext(workflow) { let stepsPrev = {} let stepsNext = {} if (workflow && workflow.data.oldStepInfo) { Object.entries(workflow.data.oldStepInfo).forEach(([k,v]) => { stepsPrev[k] = v.previous || []; stepsNext[k] = v.next || []; }); } // mock data // // first in list is most recent // stepsPrev = { // '-1': [ 3 ], // 0: [ -1 ], // 1: [ 0 ], // 2: [ 1 ], // 3: [ 2 ], // } // stepsNext = { // '-1': [ 0 ], // 0: [ 1 ], // 1: [ 2 ], // 2: [ 3 ], // 3: [ -1 ], // } // // stepsPrev = { // '-1': [ 2 ], // 0: [ -1 ], // 1: [ 1, 4, 0 ], // 2: [ 3, 1 ], // 3: [ 2 ], // 4: [ 1 ], // } // stepsNext = { // '-1': [ 0 ], // 0: [ 1 ], // 1: [ 2, 1, 4, 0 ], // 2: [ -1, 3 ], // 3: [ 2 ], // 4: [ 1 ], // } // // even more complex // stepsPrev = { // '-1': [ 2 ], // 0: [ 3, -1 ], // 1: [ 1, 4, 0 ], // 2: [ 3, 1 ], // 3: [ 2, 0 ], // 4: [ 1 ], // } // stepsNext = { // '-1': [ 0 ], // 0: [ 1, 3 ], // 1: [ 2, 1, 4, 0 ], // 2: [ -1, 3 ], // 3: [ 2, 0 ], // 4: [ 1 ], // } return [stepsPrev, stepsNext]; } function stringify(data) { return JSON.stringify(data, null, 2); }