You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@brooklyn.apache.org by he...@apache.org on 2022/10/21 13:58:10 UTC

[brooklyn-ui] 01/24: ui for workflow on activities detail page

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 <al...@cloudsoft.io>
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;