This is an automated email from the ASF dual-hosted git repository. heneveld pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/brooklyn-ui.git
commit da317de996accffe0af7e3b49f477b0fded6ec24 Author: Alex Heneveld <[email protected]> AuthorDate: Mon Oct 3 18:39:05 2022 +0100 ui for workflow on activities detail page shows list of steps with arrows, details, references to tasks --- .../entity-effector/entity-effector.less | 8 +- .../components/providers/entity-api.provider.js | 14 +- .../components/task-list/task-list.directive.js | 1 - .../components/workflow/workflow-step.directive.js | 179 +++++++++++ .../workflow/workflow-step.template.html | 194 +++++++++++ .../workflow/workflow-steps.directive.js | 354 +++++++++++++++++++++ .../app/components/workflow/workflow-steps.less | 245 ++++++++++++++ .../workflow/workflow-steps.template.html | 35 ++ ui-modules/app-inspector/app/index.js | 3 + ui-modules/app-inspector/app/index.less | 1 + .../inspect/activities/detail/detail.controller.js | 32 +- .../main/inspect/activities/detail/detail.less | 2 - .../inspect/activities/detail/detail.template.html | 26 +- ui-modules/shared/style/first.less | 5 + 14 files changed, 1087 insertions(+), 12 deletions(-) diff --git a/ui-modules/app-inspector/app/components/entity-effector/entity-effector.less b/ui-modules/app-inspector/app/components/entity-effector/entity-effector.less index 88872833..dd6eaf97 100644 --- a/ui-modules/app-inspector/app/components/entity-effector/entity-effector.less +++ b/ui-modules/app-inspector/app/components/entity-effector/entity-effector.less @@ -83,10 +83,10 @@ border-top-right-radius: 12px; } } - .effector-succeeded { color: #363; } - .effector-failed { color: #820; } - .effector-cancelled { color: #660; } - .effector-active { color: #6a2; } + .effector-succeeded { color: @color-succeeded; } + .effector-failed { color: @color-failed; } + .effector-cancelled { color: @color-cancelled; } + .effector-active { color: @color-active; } } } } \ No newline at end of file diff --git a/ui-modules/app-inspector/app/components/providers/entity-api.provider.js b/ui-modules/app-inspector/app/components/providers/entity-api.provider.js index f8d86e23..8ec4e2bd 100644 --- a/ui-modules/app-inspector/app/components/providers/entity-api.provider.js +++ b/ui-modules/app-inspector/app/components/providers/entity-api.provider.js @@ -70,8 +70,10 @@ function EntityApi($http, $q) { startEntityAdjunct: startEntityAdjunct, stopEntityAdjunct: stopEntityAdjunct, destroyEntityAdjunct: destroyEntityAdjunct, - updateEntityAdjunctConfig: updateEntityAdjunctConfig - + updateEntityAdjunctConfig: updateEntityAdjunctConfig, + + getWorkflows: getWorkflows, + getWorkflow: getWorkflow, }; function getEntity(applicationId, entityId) { @@ -187,5 +189,11 @@ function EntityApi($http, $q) { } function updateEntityAdjunctConfig(applicationId, entityId, adjunctId, configId, data) { return $http.post('/v1/applications/'+ applicationId +'/entities/' + entityId + '/adjuncts/' + adjunctId + '/config/' + configId, data); - } + } + function getWorkflows(applicationId, entityId) { + return $http.get('/v1/applications/'+ applicationId +'/entities/' + entityId + '/workflows/', {observable: true, ignoreLoadingBar: true}); + } + function getWorkflow(applicationId, entityId, workflowId) { + return $http.get('/v1/applications/'+ applicationId +'/entities/' + entityId + '/workflow/' + workflowId, {observable: true, ignoreLoadingBar: true}); + } } \ No newline at end of file diff --git a/ui-modules/app-inspector/app/components/task-list/task-list.directive.js b/ui-modules/app-inspector/app/components/task-list/task-list.directive.js index 457d0268..8f9ac4e3 100644 --- a/ui-modules/app-inspector/app/components/task-list/task-list.directive.js +++ b/ui-modules/app-inspector/app/components/task-list/task-list.directive.js @@ -190,7 +190,6 @@ function topLevelTasks(tasks) { return tasks.filter(t => isTopLevelTask(t, tasksById)); } - export function timeAgoFilter() { function timeAgo(input) { return fromNow(input); diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-step.directive.js b/ui-modules/app-inspector/app/components/workflow/workflow-step.directive.js new file mode 100644 index 00000000..9a322297 --- /dev/null +++ b/ui-modules/app-inspector/app/components/workflow/workflow-step.directive.js @@ -0,0 +1,179 @@ +/* + * 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-step.template.html"; +import angular from "angular"; +import jsyaml from 'js-yaml'; + +const MODULE_NAME = 'inspector.workflow-step'; + +angular.module(MODULE_NAME, []) + .directive('workflowStep', workflowStepDirective); + +export default MODULE_NAME; + +let count = 0; + +export function workflowStepDirective() { + return { + template: template, + restrict: 'E', + scope: { + workflow: '=', + task: '=?', + step: '<', // definition + stepIndex: '<', + expanded: '=', + onSizeChange: '=', + }, + controller: ['$sce', '$scope', controller], + controllerAs: 'vm', + }; + + function controller($sce, $scope) { + try { + let vm = this; + + let step = $scope.step; + let index = $scope.stepIndex; + + vm.stepDetails = () => stepDetails($sce, $scope.workflow, step, index, $scope.expanded); + vm.toggleExpandState = () => { + $scope.expanded = !$scope.expanded; + if ($scope.onSizeChange) $scope.onSizeChange(); + } + vm.stringify = stringify; + vm.yaml = (data) => jsyaml.dump(data); + vm.yamlOrPrimitive = (data) => typeof data === "string" ? data : vm.yaml(data); + vm.nonEmpty = (data) => data && (data.length || Object.keys(data).length); + + $scope.json = null; + $scope.jsonMode = null; + vm.showJson = (mode, json) => { + $scope.jsonMode = mode; + $scope.json = json ? stringify(json) : null; + } + + if (typeof step === 'string') { + $scope.stepPrefixClass = 'step-index'; + $scope.stepPrefix = index + 1; + $scope.stepTitleDetail = step; + } else { + + let shorthand = step.userSuppliedShorthand || step.s || step.shorthand; + $scope.stepTitleDetail = shorthand; + if (step.name) { + $scope.stepPrefixClass = 'step-name'; + $scope.stepPrefix = step.name; + } else { + if (step.id) { + $scope.stepPrefixClass = 'step-id'; + $scope.stepPrefix = step.id; + } else { + $scope.stepPrefixClass = 'step-index'; + $scope.stepPrefix = index + 1; + + if (!shorthand) { + $scope.stepTitleDetail = step.type || ''; + if (step.input) $scope.stepTitleDetail += ' ...'; + } + } + } + } + + function updateData() { + let workflow = $scope.workflow; + workflow.data = workflow.data || {}; + $scope.workflowStepClasses = []; + if (workflow.data.currentStepIndex === index) $scope.workflowStepClasses.push('current-step'); + + $scope.isCurrent = (workflow.data.currentStepIndex === index); + $scope.isRunning = (workflow.data.status === 'RUNNING'); + $scope.isWorkflowError = (workflow.data.status && workflow.data.status.startsWith('ERROR')); + $scope.osi = workflow.data.oldStepInfo[index] || {}; + $scope.stepContext = ($scope.isCurrent ? workflow.data.currentStepInstance : $scope.osi.context) || {}; + + $scope.isFocusStep = $scope.workflow.tag && ($scope.workflow.tag.stepIndex === index); + $scope.isFocusTask = false; + + if ($scope.task) { + if ($scope.stepContext.taskId === $scope.task.id) { + $scope.isFocusTask = true; + + } else if ($scope.isFocusStep) { + // TODO other instance of this tag selected + } + } + } + $scope.$watch('workflow', updateData); + updateData(); + + } catch (error) { + console.log("error showing workflow step", error); + // the ng-repeat seems to swallow and mask any error in the above - can't understand why! but log it here in case something breaks. + throw error; + } + } + +} + +function stepDetails($sce, workflow, step, index, expanded) { + let v; + if (typeof step === 'string') { + v = '<span class="step-index">'+_.escape(index+1)+'</span> '; + v += ' <span class="step-body">' + _.escape(step) + '</span>'; + } else { + let shorthand = step.userSuppliedShorthand || step.s || step.shorthand; + if (step.name) { + v = '<span class="step-name">' + _.escape(step.name) + '</span>'; + if (shorthand) { + v += ' <span class="step-body">' + _.escape(shorthand) + '</span>'; + } + } else { + if (step.id) { + v = '<span class="step-id">' + _.escape(step.id) + '</span>'; + if (shorthand) { + v += ' <span class="step-body">' + _.escape(shorthand) + '</span>'; + } + } else { + v = '<span class="step-index">'+_.escape(index+1)+'</span> '; + if (shorthand) { + v += '<span class="step-body">' + _.escape(shorthand); + } else { + v += _.escape(step.type); + if (step.input) v += ' ...'; + } + v += '</span>'; + } + } + } + v = '<div class="step-block-title">'+v+'</div>'; + + if (expanded) { + v += '<br/>'; + const oldStepInfo = (workflow.data.oldStepInfo || {})[index] + if (oldStepInfo) { + v += '<pre>' + _.escape(stringify(oldStepInfo)) + '</pre>'; + } else { + v += _.escape("Step has not been run yet."); + } + } + return $sce.trustAsHtml(v); +} + +function stringify(data) { return JSON.stringify(data, null, 2); } diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-step.template.html b/ui-modules/app-inspector/app/components/workflow/workflow-step.template.html new file mode 100644 index 00000000..b3ac63c9 --- /dev/null +++ b/ui-modules/app-inspector/app/components/workflow/workflow-step.template.html @@ -0,0 +1,194 @@ +<!-- + 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. +--> +<div class="workflow-step-outer"> + + <div class="workflow-step-status-indicators"> + <span ng-if="isCurrent"> + <span ng-if="isRunning" class="running-status"> + <brooklyn-status-icon value="STARTING"></brooklyn-status-icon> + </span> + </span> + + <span ng-if="osi.countCompleted && osi.countStarted === osi.countStarted"> + <span class="color-succeeded"> + <i class="fa fa-check-circle"></i> + </span> +<!-- <span ng-if="osi.countCompleted > 1">{{ osi.countCompleted }}</span>--> + </span> + <span ng-if="osi.countStarted && osi.countStarted != osi.countCompleted && !(isCurrent && isRunning)"> + <span class="color-failed" ng-if="isWorkflowError"> + <i class="fa fa-times-circle"></i> + </span> + <span class="color-cancelled" ng-if="!isWorkflowError"> + <i class="fa fa-exclamation-circle"></i> + </span> + </span> + </div> + + <div class="workflow-step" id="workflow-step-{{stepIindex}}" ng-class="vm.getWorkflowStepClasses(stepIndex)"> + <div class="rhs-icons"> + <div ng-if="isFocusTask" class="workflow-step-pill focus-step"> + selected + </div> + <div ng-click="vm.toggleExpandState()" class="expand-toggle"> + <i ng-class="expanded ? 'fa fa-chevron-up' : 'fa fa-chevron-down'"></i> + </div> + </div> + + <div class="step-block-title"> + <span ng-class="stepPrefixClass">{{ stepPrefix }}</span> + <span class="step-title-detail" ng-if="stepTitleDetail">{{ stepTitleDetail }}</span> + </div> + + <div ng-if="expanded" class="step-details"> + <div ng-if="osi.countStarted" class="space-above"> + <div> + <span ng-if="osi.countCompleted == osi.countStarted"> + <span ng-if="osi.countCompleted > 1"> + This step has run + <span ng-if="osi.countCompleted == 2"> + twice, + </span> + <span ng-if="osi.countCompleted > 2"> + {{ osi.countCompleted }} times, + </span> + most recently + </span> + <span ng-if="osi.countCompleted == 1"> + This step ran + </span> + </span> + <span ng-if="osi.countCompleted != osi.countStarted"> + <span ng-if="isCurrent"> + <span ng-if="osi.countCompleted == osi.countStarted - 1"> + This step is currently running + </span> + <span ng-if="osi.countCompleted <= osi.countStarted - 2"> + This step has had errors previously and is currently running + </span> + </span> + <span ng-if="!isCurrent"> + <span ng-if="osi.countStarted == 1"> + This step had errors when it ran + </span> + <span ng-if="osi.countStarted > 2 && osi.countCompleted==0"> + This step has had errors on all previous runs, including when last run + </span> + <span ng-if="osi.countStarted > 2 && osi.countCompleted>0"> + This step has had errors on some previous runs. It most recently ran + </span> + </span> + </span> + + <span ng-if="isFocusTask"> + in this task ({{ stepContext.taskId }}). + </span> + <span ng-if="!isFocusTask"> + in <a ui-sref="main.inspect.activities.detail({applicationId: workflow.applicationId, entityId: workflow.entityId, activityId: stepContext.taskId })">Task {{ stepContext.taskId }}</a>. + </span> + </div> + + <div ng-if="isFocusStep && !isFocusTask" class="space-above"> + <b>The currently selected task ({{ task.id }}) is for a previous invocation of this step.</b> + </div> + + <div class="more-space-above"> + <div class="data-row" ng-if="step.name"><div class="A">Name</div> <div class="B">{{ step.name }}</div></div> + <div class="data-row" ng-if="step.id"><div class="A">ID</div> <div class="B fixed-width">{{ step.id }}</div></div> + <div class="data-row"><div class="A">Step Number</div> <div class="B">{{ stepIndex+1 }}</div></div> + <div class="data-row"><div class="A">Definition</div> <div class="B multiline-code">{{ vm.yamlOrPrimitive(step) }}</div></div> + </div> + + <div ng-if="osi.countStarted > 1 && osi.countStarted > osi.countCompleted" class="space-above"> + <div class="data-row"><div class="A">Runs</div> <div class="B"><b>{{ osi.countStarted }}</b></div></div> + <div class="data-row"><div class="A">Succeeded</div> <div class="B">{{ osi.countCompleted }}</div></div> + <div class="data-row"><div class="A">Failed</div> <div class="B">{{ osi.countCompleted - osi.countStarted - (isCurrent ? 1 : 0) }}</div></div> + </div> + + <div class="more-space-above" ng-if="stepContext.taskId"> + <div class="data-row"> + <div class="A"><span ng-if="isCurrent">CURRENT</span><span ng-if="!isCurrent">LAST</span> EXECUTION</div> + <div class="B"> + <span ng-if="isFocusTask"> + Task {{ stepContext.taskId }} + </span> + <span ng-if="!isFocusTask"> + <a ui-sref="main.inspect.activities.detail({applicationId: workflow.applicationId, entityId: workflow.entityId, activityId: stepContext.taskId })">Task {{ stepContext.taskId }}</a> + </span> + </div> + </div> + <div ng-if="!isFocusStep || isFocusTask"> + <div class="data-row nested"><div class="A">Preceeded by</div> <div class="B"> + <span ng-if="osi.previousTaskId"> + Step {{ osi.previous[0]+1 }} + (<a ui-sref="main.inspect.activities.detail({applicationId: workflow.applicationId, entityId: workflow.entityId, activityId: osi.previousTaskId })" + >Task {{ osi.previousTaskId }}</a>) + </span> + <span ng-if="!osi.previousTaskId">(workflow start)</span> + </div></div> + <div class="data-row nested" ng-if="!isCurrent"><div class="A">Followed by</div> <div class="B"> + <span ng-if="osi.nextTaskId"> + Step {{ osi.next[0]+1 }} + (<a ui-sref="main.inspect.activities.detail({applicationId: workflow.applicationId, entityId: workflow.entityId, activityId: osi.nextTaskId })" + >Task {{ osi.nextTaskId }}</a>) + </span> + <span ng-if="!osi.nextTaskId">(workflow end)</span> + </div></div> + + <div class="data-row nested" ng-if="osi.workflowScratch"><div class="A">Workflow Vars</div> <div class="B multiline-code">{{ vm.yaml(osi.workflowScratch) }}</div></div> + <div class="data-row nested" ng-if="stepContext.input"><div class="A">Input</div> <div class="B multiline-code">{{ vm.yaml(stepContext.input) }}</div></div> + <div class="data-row nested" ng-if="!isCurrent && stepContext.output"><div class="A">Output</div> <div class="B multiline-code">{{ vm.yaml(stepContext.output) }}</div></div> + </div> + </div> + + </div> + <div ng-if="!osi.countStarted" class="space-above"> + This step has not been run<span ng-if="isRunning"> yet</span>. + </div> + + <div class="more-space-above" ng-if="vm.nonEmpty(stepContext) || vm.nonEmpty(step) || vm.nonEmpty(osi)"> + + <div class="btn-group right" uib-dropdown> + <button id="single-button" type="button" class="btn btn-select-dropdown pull-right" uib-dropdown-toggle> + View data <span class="caret"></span> + </button> + <ul class="dropdown-menu pull-right" uib-dropdown-menu role="menu" aria-labelledby="single-button"> + <li role="menuitem" > <a href="" ng-click="vm.showJson('stepContext', stepContext)" ng-class="{'selected' : jsonMode === 'stepContext'}"> + <i class="fa fa-check check"></i> + Last Execution Context</a> </li> + <li role="menuitem" > <a href="" ng-click="vm.showJson('osi', osi)" ng-class="{'selected' : jsonMode === 'osi'}"> + <i class="fa fa-check check"></i> + Executions Record</a> </li> + <li role="menuitem" > <a href="" ng-click="vm.showJson('step', step)" ng-class="{'selected' : jsonMode === 'step'}"> + <i class="fa fa-check check"></i> + Step Definition</a> </li> + <li role="menuitem" > <a href="" ng-click="vm.showJson(null)" ng-class="{'selected' : jsonMode === null}"> + <i class="fa fa-check check"></i> + None</a> </li> + </ul> + </div> + + <pre ng-if="json" class="space-above">{{ json }}</pre> + </div> + </div> + + </div> + +</div> + diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js b/ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js new file mode 100644 index 00000000..1d509ada --- /dev/null +++ b/ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js @@ -0,0 +1,354 @@ +/* + * 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: '=?', + }, + controller: ['$sce', '$timeout', '$scope', '$element', controller], + controllerAs: 'vm', + }; + + function controller($sce, $timeout, $scope, $element) { + let vm = this; + //console.log("controller for workflow steps", $scope.workflow); + + vm.stringify = stringify; + + 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 && $scope.workflow.tag.stepIndex) { + $scope.expandStates[$scope.workflow.tag.stepIndex] = true; + } + + vm.onSizeChange = () => $timeout(()=>recompute($scope, $element)); + + $scope.$watch('workflow', vm.onSizeChange); + vm.onSizeChange(); + } + + function recompute($scope, $element) { + let svg = $element[0].querySelector('#workflow-step-arrows'); + + let steps = $element[0].querySelectorAll('div'); + // let steps = $element[0].querySelectorAll('.workflow-steps-main'); + steps = $element[0].querySelectorAll('.workflow-step'); + let arrows = makeArrows($scope.workflow, steps); + + svg.innerHTML = arrows.join('\n'); + } +} + +function makeArrows(workflow, steps) { + 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 fill="#C0C0C0" 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.color || (opts.colorEnd && opts.colorEnd==opts.colorStart ? opts.colorEnd : '#000'); + + const rightFarEdge = 56; + 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 = + 'stroke="'+color+'"'; + + let standard = + 'stroke-width="'+(opts.lineWidth || strokeWidth)+'" '+ + 'fill="transparent" '+ + '/>'; + 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; + } + + return '<path d="M ' + rightFarEdge + ' ' + y1 + + // ' L ' + r0 + ' ' + y1 + ' ' + + ' C ' + controlPointStart + ' ' + y1 + ', ' + leftActive + ' ' + (yM - yMCH) + ', ' + leftActive + ' ' + yM + ' ' + + ' S ' + controlPointEnd + ' ' + y2 + ', ' + rightArrowheadStart + ' ' + y2 + '" '+standard; + } + + function stepY(n) { + if (n==-1) return 'start/end'; + if (!steps || n<0 || n>=steps.length) { + 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); + } + + function colorFor(step, references) { + if (!references) return 'red'; + const i = references.indexOf(step); + if (i==-1) 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 jumpSizes = {1: 0}; + for (var i = -1; i < steps.length - 1; i++) { + const prevsHere = stepsPrev[i]; + if (prevsHere && prevsHere.length) { + prevsHere.forEach(prev => { + if (i!=-1 && prev!=-1 && i!=prev) { + jumpSizes[Math.abs(prev - i)] = true; + } + }); + } + } + jumpSizes = Object.keys(jumpSizes).sort(); + + 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}); + } + + for (var i = -1; i < steps.length; i++) { + const prevsHere = stepsPrev[i]; + if (prevsHere && prevsHere.length) { + let insertionPoint = 0; + prevsHere.forEach(prev => { + const colorStart = colorFor(i, stepsNext[prev]); + const colorEnd = colorFor(prev, prevsHere); + + // 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.) + arrows.splice(insertionPoint, 0, arrowStep2(prev, i, { colorStart, colorEnd })); + }); + } + } + + // 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.id) { + indexOfId[s.id] = i; + } + } + if (steps.length>0) { + arrows.splice(0, 0, arrowStep2(-1, 0, {color: '#C0C0C0', arrowheadId: 'arrowhead-gray', dashLength: 8 })); + } + for (var i = 0; i < steps.length; i++) { + const s = workflow.data.stepsDefinition[i]; + var next = null; + if (s.next) { + if (indexOfId[s.next]) { + next = indexOfId[s.next]; + } else { + next = null; + } + } else { + if (s.type === 'return' || (s.userSuppliedShorthand && s.userSuppliedShorthand.startsWith("return"))) { + next = -1; + } else { + next = i + 1; + if (next >= steps.length) next = -1; //end + } + } + if (next!=null) arrows.splice(0, 0, arrowStep2(i, next, { color: '#C0C0C0', arrowheadId: 'arrowhead-gray', dashLength: 8 })); + } + + // put 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); } diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-steps.less b/ui-modules/app-inspector/app/components/workflow/workflow-steps.less new file mode 100644 index 00000000..0a9bd803 --- /dev/null +++ b/ui-modules/app-inspector/app/components/workflow/workflow-steps.less @@ -0,0 +1,245 @@ +/* + * 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. + */ +.workflow-steps { + + position: relative; + + .workflow-steps-main { + position: relative; + } + + .workflow-step { + margin-left: 60px; + margin-right: 60px; + margin-top: 12px; + margin-bottom: 12px; + + padding-left: 10px; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 5px; + + border: solid @gray-light-lighter 1px; + + // could do borders around active/error steps. but instead icons at left. + //&.current-step { + // border: solid @gray-light-lighter 2px; + // padding-left: 3px; + // padding-right: 3px; + //} + } + //&.workflow-status-RUNNING { + // .workflow-step.current-step { + // border: solid #363 2px; + // padding-left: 3px; + // padding-right: 3px; + // } + //} + //&.workflow-error { + // .workflow-step.current-step { + // border: solid #820 2px; + // padding-left: 3px; + // padding-right: 3px; + // } + //} + + .workflow-step { + .rhs-icons { + float: right; + display: flex; + gap: 1ex; + .expand-toggle { + cursor: pointer; + } + } + .workflow-step-pill { + padding: 2px 6px; + border-radius: 12px; + background: @gray-lighter; + font-size: 75%; + &.focus-step { + background: @primary-100; + } + } + .step-block-title { + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding-right: 1.5ex; + + .step-name, .step-id, .step-index { + padding-right: 1.5ex; + } + + .step-name { + font-weight: 600; + font-size: 100%; + } + + .step-id { + .monospace(); + font-size: 85%; + font-weight: 600; + } + + .step-index { + font-size: 90%; + //font-style: italic; + } + + .step-title-detail { + .monospace(); + font-size: 85%; + font-weight: 300; + color: @gray-light; + } + } + .step-details { + margin-top: 12px; + .space-above { + margin-top: 6px; + } + .more-space-above, .data-row.more-space-above { + margin-top: 12px; + } + .data-row { + display: flex; + margin-top: 3px; + margin-bottom: 3px; + .A { + flex: 0 0 auto; + width: 30%; + overflow: hidden; + white-space: nowrap; + color: @gray-light; + font-family: @font-family-monospace; + font-size: 85%; + text-transform: uppercase; + } + .B { + flex: 1 1 auto; + } + + &.nested { + .A { + padding-left: 1em; + } + } + } + .btn-group.right { + width: 100%; + + > .pull-right { + float: none; + } + > .dropdown-menu { + width: auto; + li a { + padding-left: 2em; + } + } + + .selected { + .check { + margin-left: -1.5em; + display: block; + width: 0; + height: 0; + overflow: visible; + margin-top: 3px; + margin-bottom: -3px; + } + } + .check { + display: none; + } + } + } + } + + .workflow-step-status-indicators { + //position: absolute; + //width: 60px; + //text-align: right; + //padding-right: 1ex; + //margin-top: 8px; + position: absolute; + width: 60px; + text-align: right; + padding-right: 1ex; + margin-top: 0; + display: flex; + gap: 6px; + margin-top: 3px; + height: 30px; + align-items: center; + justify-content: end; + + .color-succeeded { color: @color-succeeded; } + .color-failed { color: @color-failed; } + .color-cancelled { color: @color-cancelled; } + .color-active { color: @color-active; } + + .running-status { + svg { + //margin: 0 auto; + //background: none; + //display: block; + // + width: 18px; + height: 18px; + margin-top: 2px; + } + } + + i.fa { + font-size: 20px; + } + + //// same as entity-effector.less + //.effector-pill { + // background: @gray-lighter; + // padding: 2px 6px; + // &:first-child { + // padding-left: 10px; + // border-bottom-left-radius: 12px; + // border-top-left-radius: 12px; + // } + // &:last-child { + // padding-right: 10px; + // border-bottom-right-radius: 12px; + // border-top-right-radius: 12px; + // } + //} + } + + .multiline-code { + white-space: pre; + overflow: scroll; + max-height: 100px; + } + .multiline-code, .fixed-width { + .monospace(); + font-size: 85%; + } + .fixed-width { + overflow: hidden; + } + +} diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-steps.template.html b/ui-modules/app-inspector/app/components/workflow/workflow-steps.template.html new file mode 100644 index 00000000..309ce4a2 --- /dev/null +++ b/ui-modules/app-inspector/app/components/workflow/workflow-steps.template.html @@ -0,0 +1,35 @@ +<!-- + 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. +--> +<div class="workflow-steps" ng-class="vm.getWorkflowStepsClasses()"> + + <svg width="100%" height="100%" style="position: absolute;"> + <g transform="scale(-1,1)" transform-origin="center" id="workflow-step-arrows"> + </g> + </svg> + + <div style="position: relative;"> + <div ng-if="workflow.data.stepsDefinition" class="workflow-steps-main"> + <div ng-repeat="step in workflow.data.stepsDefinition track by $index" id="workflow-step-outer-{{$index}}"> + <workflow-step workflow="workflow" task="task" step="step" step-index="$index" expanded="expandStates[$index]" on-size-change="vm.onSizeChange"></workflow-step> + </div> + </div> + </div> + +</div> + diff --git a/ui-modules/app-inspector/app/index.js b/ui-modules/app-inspector/app/index.js index 219e842f..c82707b3 100755 --- a/ui-modules/app-inspector/app/index.js +++ b/ui-modules/app-inspector/app/index.js @@ -48,6 +48,8 @@ import taskList from "components/task-list/task-list.directive"; import taskSunburst from "components/task-sunburst/task-sunburst.directive"; import stream from "components/stream/stream.directive"; import adjunctsList from "components/adjuncts-list/adjuncts-list"; +import workflowSteps from "components/workflow/workflow-steps.directive"; +import workflowStep from "components/workflow/workflow-step.directive"; import {mainState} from "views/main/main.controller"; import {inspectState} from "views/main/inspect/inspect.controller"; import {summaryState, specToLabelFilter} from "views/main/inspect/summary/summary.controller"; @@ -69,6 +71,7 @@ angular.module('brooklynAppInspector', [ngResource, ngCookies, ngSanitize, uiRou brServerStatus, brIconGenerator, brInterstitialSpinner, brooklynModuleLinks, brSensitiveField, brooklynUserManagement, brYamlEditor, brWebNotifications, brExpandablePanel, 'xeditable', brLogbook, apiProvider, entityTree, loadingState, configSensorTable, entityEffector, entityPolicy, breadcrumbNavigation, taskList, taskSunburst, stream, adjunctsList, + workflowSteps, workflowStep, managementDetail, brandAngularJs]) .provider('catalogApi', catalogApiProvider) .provider('apiObserverInterceptor', apiObserverInterceptorProvider) diff --git a/ui-modules/app-inspector/app/index.less b/ui-modules/app-inspector/app/index.less index 9988be2f..8e446770 100644 --- a/ui-modules/app-inspector/app/index.less +++ b/ui-modules/app-inspector/app/index.less @@ -44,6 +44,7 @@ @import "components/stream/stream.less"; @import "components/task-list/task-list.less"; @import "components/task-sunburst/task-sunburst.less"; +@import "components/workflow/workflow-steps.less"; @import "components/breadcrumb-navigation/breadcrumb-navigation.less"; @import "components/adjuncts-list/adjuncts-list.less"; diff --git a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.controller.js b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.controller.js index d0e29f35..02388f97 100644 --- a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.controller.js +++ b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.controller.js @@ -42,7 +42,8 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou entityId: entityId, activityId: activityId, childFilter: {'EFFECTOR': true, 'SUB-TASK': false}, - accordion: {summaryOpen: true, subTaskOpen: true, streamsOpen: true} + accordion: {summaryOpen: true, subTaskOpen: true, streamsOpen: true, workflowOpen: true}, + workflow: {}, }; vm.modalTemplate = modalTemplate; @@ -67,6 +68,28 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou } } + if ((vm.model.activity.tags || []).find(t => t=="WORKFLOW")) { + const workflowTag = findWorkflowTag(vm.model.activity); + if (workflowTag) { + vm.model.workflow.tag = workflowTag; + vm.model.workflow.loading = 'loading'; + entityApi.getWorkflow(applicationId, entityId, workflowTag.workflowId).then(wResponse => { + vm.model.workflow.data = wResponse.data; + vm.model.workflow.loading = 'loaded'; + vm.model.workflow.applicationId = applicationId; + vm.model.workflow.entityId = entityId; + + observers.push(wResponse.subscribe((wResponse2)=> { + // change the workflow object so widgets get refreshed + vm.model.workflow = { ...vm.model.workflow, data: wResponse2.data }; + })); + }).catch(error => { + console.log("ERROR loading workflow " + workflowTag.workflowId, error); + vm.model.workflow.loading = 'error'; + }); + } + } + vm.error = undefined; observers.push(response.subscribe((response)=> { vm.model.activity = response.data; @@ -151,6 +174,7 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou }; vm.stringifyActivity = () => JSON.stringify(vm.model.activity, null, 2); + vm.stringify = (data) => JSON.stringify(data, null, 2); vm.invokeEffector = (effectorName, effectorParams) => { entityApi.invokeEntityEffector(applicationId, entityId, effectorName, effectorParams).then((response) => { @@ -183,3 +207,9 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou } } + +function findWorkflowTag(task) { + if (!task) return null; + if (!task.tags) return null; + return task.tags.find(t => t.workflowId); +} \ No newline at end of file diff --git a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.less b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.less index bdad7360..b6eb05e5 100644 --- a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.less +++ b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.less @@ -17,8 +17,6 @@ * under the License. */ -@gray-light-lighter: lighten(@gray-light, 20%); - .activity-detail { .activity-header { -webkit-font-smoothing: antialiased; diff --git a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.template.html b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.template.html index 0598078c..25eada16 100644 --- a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.template.html +++ b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/detail.template.html @@ -157,10 +157,34 @@ </div> </br-collapsible> + <br-collapsible state="vm.model.accordion.workflowOpen" + ng-if="vm.model.workflow"> + <heading> Workflow</heading> + + <div class="workflow-body"> + <div ng-if="vm.model.workflow.loading == 'loaded'"> + <p style="margin-top: 12px; margin-bottom: 24px;"> + This task is for + <span ng-if="vm.model.workflow.tag.stepIndex">step <b>{{ vm.model.workflow.tag.stepIndex+1 }}</b> + in workflow + <a ui-sref="main.inspect.activities.detail({entityId: vm.model.entityId, activityId: vm.model.workflow.data.taskId})"> + <b>{{vm.model.workflow.data.name}}</b>. + </a> + </span> + <span ng-if="!vm.model.workflow.tag.stepIndex"> workflow <b>{{vm.model.workflow.data.name}}</b>.</span> + </p> + <workflow-steps workflow="vm.model.workflow" task="vm.model.activity"></workflow-steps> + </div> + <div ng-if="vm.model.workflow.loading != 'loaded'"> + <loading-state error="vm.model.workflow.loading !== 'loading' ? 'Details of this workflow are no longer available.' : ''"></loading-state> + </div> + </div> + </br-collapsible> + <br-collapsible state="vm.model.accordion.subTaskOpen" ng-if="vm.model.activityChildren && vm.model.activityChildren.length > 0"> <heading> Sub-tasks</heading> - + <div class="row"> <div ng-class="{ 'col-md-12': true, 'col-lg-8': !vm.wideKilt && vm.isNonEmpty(vm.model.activitiesDeep), 'col-lg-12': vm.wideKilt || !vm.isNonEmpty(vm.model.activitiesDeep)}"> <task-list tasks="vm.model.activityChildren" task-type="activityChildren" filtered-callback="vm.onFilteredActivitiesChange"></task-list> diff --git a/ui-modules/shared/style/first.less b/ui-modules/shared/style/first.less index 436e0c4c..faf640f7 100644 --- a/ui-modules/shared/style/first.less +++ b/ui-modules/shared/style/first.less @@ -28,6 +28,11 @@ @import '~font-awesome/less/font-awesome'; @navbar-divider-color: rgba(60, 85, 136, .5); +@gray-light-lighter: lighten(@gray-light, 20%); +@color-succeeded: #363; +@color-failed: #820; +@color-cancelled: #660; +@color-active: #6a2; .navbar-text { float: left;
