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:27 UTC

[brooklyn-ui] 18/24: ui for replay, replay scope improvements, and misc tidies to recent workflow ui

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 f66c75d59b97585dd0244478efb8c1b1d9acabb9
Author: Alex Heneveld <al...@cloudsoft.io>
AuthorDate: Tue Oct 11 01:57:00 2022 +0100

    ui for replay, replay scope improvements, and misc tidies to recent workflow ui
---
 .../components/providers/entity-api.provider.js    |   2 +-
 .../components/task-list/task-list.directive.js    |   7 +-
 .../workflow/workflow-step.template.html           |   4 +-
 .../workflow/workflow-steps.directive.js           |  19 +-
 .../app/components/workflow/workflow-steps.less    |  30 +--
 .../inspect/activities/detail/detail.controller.js | 212 +++++++++++++--------
 .../main/inspect/activities/detail/detail.less     |  17 ++
 .../inspect/activities/detail/detail.template.html |  69 +++++--
 .../inspect/activities/detail/dropdown-nested.js   |  71 ++++---
 .../providers/api-observer-interceptor.provider.js |   2 +-
 10 files changed, 264 insertions(+), 169 deletions(-)

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 aa34cfd3..39c28fe0 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
@@ -199,6 +199,6 @@ function EntityApi($http, $q) {
     }
     function replayWorkflow(applicationId, entityId, workflowId, step, options) {
         return $http.post('/v1/applications/'+ applicationId +'/entities/' + entityId + '/workflows/' + workflowId
-            + '/replay/from/' + step, {params: options, observable: true, ignoreLoadingBar: true});
+            + '/replay/from/' + step, {params: options});
     }
 }
\ 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 db0d30c1..5f12c8a4 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
@@ -395,7 +395,6 @@ export function taskListDirective() {
             countAbsolute: countWorkflowsReplayedTopLevel,
         }
 
-        const filterWorkflowsReplayedNested = t => !t.isWorkflowFirstRun && t.isWorkflowLastRun && !t.isWorkflowTopLevel;
         const countWorkflowsReplayedNested = tasksAll.filter(filterWorkflowsReplayedNested).length;
         filtersFullList['_workflowReplayedNested'] = {
             display: 'Include replayed sub-workflows',
@@ -514,12 +513,14 @@ export function taskListDirective() {
     }
 }
 
+const filterWorkflowsReplayedNested = t => !t.isWorkflowFirstRun && t.isWorkflowLastRun && !t.isWorkflowTopLevel;
 
 function isScheduled(task) {
   return task && task.currentStatus && task.currentStatus.startsWith("Schedule");
 }
 
 function isTopLevelTask(t, tasksById) {
+    if (filterWorkflowsReplayedNested(t)) return false;
     if (!t.submittedByTask) return true;
     if (t.forceTopLevel) return true;
     if (t.tags && t.tags.includes("TOP-LEVEL")) return true;
@@ -533,11 +534,11 @@ function isNonTopLevelTask(t, tasksById) {
 }
 function isCrossEntityTask(t, tasksById) {
     if (isTopLevelTask(t, tasksById)) return false;
-    return t.submittedByTask.metadata.entityId !== t.entityId;
+    return t.submittedByTask && t.submittedByTask.metadata.entityId !== t.entityId;
 }
 function isNestedSameEntityTask(t, tasksById) {
     if (isTopLevelTask(t, tasksById)) return false;
-    return t.submittedByTask.metadata.entityId === t.entityId;
+    return t.submittedByTask && t.submittedByTask.metadata.entityId === t.entityId;
 }
 function filterWithId(tasks, tasksById, nextFilter) {
     if (!tasks) return tasks;
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
index 5ec2a31c..0554213c 100644
--- a/ui-modules/app-inspector/app/components/workflow/workflow-step.template.html
+++ b/ui-modules/app-inspector/app/components/workflow/workflow-step.template.html
@@ -164,14 +164,14 @@
                                 </button>
                                 <ul class="dropdown-menu" uib-dropdown-menu role="menu" aria-labelledby="workflow-button">
                                     <li role="menuitem" ng-repeat="sub in stepContext.subWorkflows" id="sub-workflow-{{ sub.workflowId }}">
-                                        <a href="" ui-sref="main.inspect.activities.detail({applicationId: sub.applicationId, entityId: sub.entityId, activityId: sub.workflowId})">
+                                        <a href="" ui-sref="main.inspect.activities.detail({applicationId: sub.applicationId, entityId: sub.entityId, activityId: sub.workflowId, workflowLatestRun: true})">
                                             <i class="fa fa-check check"></i>
                                             <span>{{ vm.getWorkflowNameFromReference(sub) }}</span>
                                             <span class="monospace">{{ sub.workflowId }}</span></a> </li>
                                 </ul>
                             </div>
                             <div class="btn-group" uib-dropdown ng-if="stepContext.subWorkflows.length==1">
-                                <a href="" ui-sref="main.inspect.activities.detail({applicationId: stepContext.subWorkflows[0].applicationId, entityId: stepContext.subWorkflows[0].entityId, activityId: stepContext.subWorkflows[0].workflowId})">
+                                <a href="" ui-sref="main.inspect.activities.detail({applicationId: stepContext.subWorkflows[0].applicationId, entityId: stepContext.subWorkflows[0].entityId, activityId: stepContext.subWorkflows[0].workflowId, workflowLatestRun: true})">
                                     <span>{{ vm.getWorkflowNameFromReference(stepContext.subWorkflows[0]) }}</span>
                                     <span class="monospace">{{ stepContext.subWorkflows[0].workflowId }}</span>
                                 </a>
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
index 143b665c..15c9dce9 100644
--- a/ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js
+++ b/ui-modules/app-inspector/app/components/workflow/workflow-steps.directive.js
@@ -88,8 +88,8 @@ function makeArrows(workflow, steps) {
     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><');
-    defs.push('<marker id="arrowhead-red" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon fill="red" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
+    defs.push('<marker id="arrowhead-gray" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-future" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
+    defs.push('<marker id="arrowhead-red" markerWidth="'+3*arrowheadWidth+'" markerHeight="'+3*arrowheadWidth+'" refX="'+0+'" refY="'+1.5*arrowheadWidth+'" orient="auto"><polygon class="fill-failed" points="0 0, '+3*arrowheadWidth+' '+1.5*arrowheadWidth+', 0 '+(3*arrowheadWidth)+'" /></marker><');
 
     if (steps) {
         let gradientCount = 0;
@@ -103,7 +103,7 @@ function makeArrows(workflow, steps) {
             }
 
             if (!opts) opts = {};
-            const color = opts.color || (opts.colorEnd && opts.colorEnd==opts.colorStart ? opts.colorEnd : '#000');
+            const color = opts.class ? '' : opts.color || (opts.colorEnd && opts.colorEnd==opts.colorStart ? opts.colorEnd : '#000');
 
             const rightFarEdge = 56;
             const rightArrowheadStart = rightFarEdge - arrowheadLength;
@@ -121,14 +121,14 @@ function makeArrows(workflow, steps) {
             const controlPointStart = controlPointRightArrowheadStart;
             const controlPointEnd = controlPointRightArrowheadStart;
 
-            const strokeConstant =
-                'stroke="'+color+'"';
+            const strokeConstant = color ? 'stroke="'+color+'"' : ''
 
             let standard =
                 'stroke-width="'+(opts.lineWidth || strokeWidth)+'" '+
                 'fill="transparent" '+
                 '/>';
-            if (!opts.hideArrowhead) standard = 'marker-end="url(#'+(opts.arrowheadId || 'arrowhead')+'" ' +standard;
+            if (opts.class) standard = 'class="'+opts.class+'" '+standard;
+            if (!opts.hideArrowhead) standard = 'marker-end="url(#'+(opts.arrowheadId || 'arrowhead')+')" ' +standard;
             if (opts.dashLength) standard = 'stroke-dasharray="'+opts.dashLength+'" '+standard;
 
             if (start) {
@@ -154,10 +154,11 @@ function makeArrows(workflow, steps) {
                 standard = 'stroke="url(#'+gradientId+')" ' + standard;
             }
 
-            return '<path d="M ' + rightFarEdge + ' ' + y1 +
+            const result = '<path d="M ' + rightFarEdge + ' ' + y1 +
                 // ' L ' + r0 + ' ' + y1 + ' ' +
                 ' C ' + controlPointStart + ' ' + y1 + ', ' + leftActive + ' ' + (yM - yMCH) + ', ' + leftActive + ' ' + yM + ' ' +
                 ' S ' + controlPointEnd + ' ' + y2 + ', ' + rightArrowheadStart + ' ' + y2 + '" '+standard;
+            return result;
         }
 
         function stepY(n) {
@@ -264,10 +265,10 @@ function makeArrows(workflow, steps) {
 
             let opts = { insertionPoint: 0 };
             if (workflow.data.currentStepIndex === i && workflow.data.status && workflow.data.status.startsWith('ERROR')) {
-                recordTransition(i, -1, { ...opts, color: 'red', arrowheadId: 'arrowhead-red' });
+                recordTransition(i, -1, { ...opts, class: 'arrow-failed', arrowheadId: 'arrowhead-red' });
             }
 
-            opts = { ...opts, color: '#C0C0C0', arrowheadId: 'arrowhead-gray', dashLength: 8 };
+            opts = { ...opts, class: 'arrow-future', arrowheadId: 'arrowhead-gray', dashLength: 8 };
 
             let next = null;
             if (s.next) {
diff --git a/ui-modules/app-inspector/app/components/workflow/workflow-steps.less b/ui-modules/app-inspector/app/components/workflow/workflow-steps.less
index f17e70bc..9948ee90 100644
--- a/ui-modules/app-inspector/app/components/workflow/workflow-steps.less
+++ b/ui-modules/app-inspector/app/components/workflow/workflow-steps.less
@@ -37,29 +37,6 @@
 
     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;
@@ -256,8 +233,15 @@
 
   svg.workflow-arrows {
     //opacity: 10%;
+
+    .arrow-failed { stroke: @color-failed; }
+    .arrow-future { stroke: @gray-lighter; }
+
+    .fill-failed { fill: @color-failed; }
+    .fill-future { fill: @gray-lighter; }
   }
   svg.workflow-arrows:hover {
     opacity: 100%;
   }
+
 }
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 a5cbe6b2..1e7aedba 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
@@ -23,12 +23,12 @@ import {makeTaskStubFromWorkflowRecord} from "../activities.controller";
 
 export const detailState = {
     name: 'main.inspect.activities.detail',
-    url: '/:activityId?workflowId',
+    url: '/:activityId?workflowId?workflowLatestRun',
     template: template,
-    controller: ['$scope', '$state', '$stateParams', '$log', '$uibModal', '$timeout', '$sanitize', '$sce', 'activityApi', 'entityApi', 'brUtilsGeneral', DetailController],
+    controller: ['$scope', '$state', '$stateParams', '$location', '$log', '$uibModal', '$timeout', '$sanitize', '$sce', 'activityApi', 'entityApi', 'brUtilsGeneral', DetailController],
     controllerAs: 'vm',
 }
-function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeout, $sanitize, $sce, activityApi, entityApi, Utils) {
+function DetailController($scope, $state, $stateParams, $location, $log, $uibModal, $timeout, $sanitize, $sce, activityApi, entityApi, Utils) {
     $scope.$emit(HIDE_INTERSTITIAL_SPINNER_EVENT);
 
     const {
@@ -39,6 +39,10 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
     $scope.workflowId = $stateParams.workflowId;
 
     let vm = this;
+    vm.redirectToWorkflowLatestRun = $stateParams.workflowLatestRun;
+    $stateParams.workflowLatestRun = null;
+    $location.search('workflowLatestRun', null)
+
     vm.model = {
         appId: applicationId,
         entityId: entityId,
@@ -51,17 +55,31 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
 
     vm.modalTemplate = modalTemplate;
     vm.wideKilt = false;
+    vm.toggleOldWorkflowRunStepDetails = () => { $scope.showOldWorkflowRunStepDetails = !$scope.showOldWorkflowRunStepDetails; }
+
     $scope.actions = {};
 
     let observers = [];
 
     if ($state.current.name === detailState.name) {
 
-        function loadWorkflow(workflowTag, optimistic) {
+        function onActivityOrWorkflowUpdate() {
+            delete $scope.actions['cancel'];
+            if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<=0) {
+                $scope.actions.cancel = { label: 'Cancel task', doAction: () => { activityApi.cancelActivity(activityId); } };
+            } else if (vm.model.workflow.data && vm.model.workflow.data.taskId && vm.model.workflow.data.status === 'RUNNING') {
+                $scope.actions.cancel = { label: 'Cancel workflow', doAction: () => { activityApi.cancelActivity(vm.model.workflow.taskId); } };
+            }
+        }
+
+        function loadWorkflow(workflowTag, opts) {
+            if (!opts) opts = {};
             if (!workflowTag) {
                 workflowTag = {}
-                optimistic = true;
+                opts.optimistic = true;
             }
+            const optimistic = opts.optimistic;
+
             vm.model.workflow.loading = 'loading';
 
             $scope.workflowId = workflowTag.workflowId || $scope.workflowId || activityId;
@@ -76,71 +94,123 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
                 vm.model.workflow.applicationId = workflowTag.applicationId;
                 vm.model.workflow.entityId = workflowTag.entityId;
 
-                $scope.actions.workflowReplays = [];
-                if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<=0) {
-                    // can't replay if active (same logic as 'cancel')
-                } else {
-                    $scope.actions.workflowReplays = [];
-                    const stepIndex = (vm.model.workflow.tag || {}).stepIndex;
+                if (opts.nonTask) {
+                    const wft = (vm.model.workflow.data.replays || []).find(t => t.taskId === activityId);
+                    if (wft) {
+                        vm.model.activity = makeTaskStubFromWorkflowRecord(vm.model.workflow.data, wft);
+                        vm.model.workflow.tag = getTaskWorkflowTag(vm.model.activity);
+                    } else {
+                        throw "Workflow task " + activityId + " not stored on workflow";
+                    }
+
+                    // give a better error
+                    vm.error = $sce.trustAsHtml('Limited information on workflow task <b>' + _.escape(activityId) + '</b>.<br/><br/>' +
+                        (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc == -1
+                            ? "The run appears to have been interrupted by a server restart or failover."
+                            : 'The workflow is known but this task is no longer stored in memory.'));
+                }
+
+                function processWorkflowData(wResponse2) {
+                    // change the workflow object so widgets get refreshed
+                    vm.model.workflow = { ...vm.model.workflow, data: wResponse2.data };
 
-                    let replayableFromStart = vm.model.workflow.data.replayableFromStart, replayableContinuing = vm.model.workflow.data.replayableLastStep>=0;
+                    const replays = (vm.model.workflow.data.replays || []);
 
-                    if (replayableContinuing) {
-                        $scope.actions.workflowReplays.push({ targetId: 'end', targetName: 'Resume '+(stepIndex>=0 ? 'workflow ' : '')+' (at step '+(vm.model.workflow.data.replayableLastStep+1)+')' });
+                    vm.model.workflow.runMultipleTimes = replays.length > 1;
+                    let workflowReplayId = activityId;
+                    if (!replays.find(r => r.taskId === workflowReplayId)) {
+                        let submittedById = ((vm.model.activity.submittedByTask || {}).metadata || {}).id;
+                        if (replays.find(r => r.taskId === submittedById)) workflowReplayId = submittedById;
+                        else workflowReplayId = null;
                     }
+                    if (workflowReplayId) {
+                        vm.model.workflow.runReplayId = workflowReplayId;
+                        vm.model.workflow.runIsLatest = workflowReplayId == (replays[replays.length - 1] || {}).taskId;
+                        vm.model.workflow.runIsOld = !vm.model.workflow.runIsLatest;
+                    }
+                    if (vm.model.workflow.runIsOld && vm.redirectToWorkflowLatestRun) {
+                        vm.redirectToWorkflowLatestRun = false;
+                        $state.go('main.inspect.activities.detail', {
+                            applicationId: applicationId,
+                            entityId: entityId,
+                            activityId: (replays[replays.length - 1] || {}).taskId,
+                        });
+                    }
+
+                    $scope.actions.workflowReplays = [];
+                    if (vm.model.workflow.data.status !== 'RUNNING') {
 
-                    // get current step, replay from that step
-                    if (stepIndex>=0) {
-                        const osi = workflow.data.oldStepInfo[stepIndex] || {};
-                        if (osi.replayableFromHere) {
-                            $scope.actions.workflowReplays.push({ targetId: ''+stepIndex, targetName: 'Replay from here (step '+(stepIndex+1) });
-                        } else {
-                            $scope.actions.workflowReplays.push({ targetId: ''+stepIndex, targetName: 'Force replay from here (step '+(stepIndex+1), force: true });
+                        $scope.actions.workflowReplays = [];
+                        const stepIndex = (vm.model.workflow.tag || {}).stepIndex;
+
+                        let replayableFromStart = vm.model.workflow.data.replayableFromStart, replayableContinuing = vm.model.workflow.data.replayableLastStep>=0;
+
+                        if (replayableContinuing) {
+                            $scope.actions.workflowReplays.push({ targetId: 'end', reason: 'Resume workflow at step '+(vm.model.workflow.data.replayableLastStep+1)+' from UI',
+                                label: 'Resume '+(stepIndex>=0 ? 'workflow ' : '')+' (at step '+(vm.model.workflow.data.replayableLastStep+1)+')' });
                         }
-                    }
 
-                    if (replayableFromStart) {
-                        let w1 = 'Restart', w2 = '(not resumable)';
-                        if (stepIndex<0) { w1 = 'Run'; w2 = 'again'; }
-                        else if (_.isNil(stepIndex)) { w2 = '(did not start)'; }
-                        else if (replayableContinuing) w2 = '';
+                        // get current step, replay from that step
+                        if (stepIndex>=0) {
+                            const osi = vm.model.workflow.data.oldStepInfo[stepIndex] || {};
+                            if (osi.replayableFromHere) {
+                                $scope.actions.workflowReplays.push({ targetId: ''+stepIndex, reason: 'Replay workflow from step '+(stepIndex+1)+' from UI',
+                                    label: 'Replay from here (step '+(stepIndex+1) });
+                            } else {
+                                $scope.actions.workflowReplays.push({ targetId: ''+stepIndex, reason: 'Force replay from step '+(stepIndex+1)+' from UI',
+                                    label: 'Force replay from here (step '+(stepIndex+1), force: true });
+                            }
+                        }
 
-                        $scope.actions.workflowReplays.push({targetId: 'start', targetName: 'Restart '+(stepIndex>=0 ? 'workflow ' : '')+reason});
-                    }
+                        if (replayableFromStart) {
+                            let w1 = 'Restart', w2 = '(not resumable)';
+                            if (stepIndex<0) { w1 = 'Run'; w2 = 'again'; }
+                            else if (_.isNil(stepIndex)) { w2 = '(did not start)'; }
+                            else if (replayableContinuing) w2 = '';
 
-                    if (!replayableFromStart) {
-                        $scope.actions.workflowReplays.push({targetId: 'start', targetName: 'Force restart', force: true});
-                    }
-                    // force replays
-                    $scope.actions.workflowReplays.forEach(r => {
-                        // could prompt for a reason
-                        const targetId = r.targetId;
-                        const opts = {};
-                        opts.reason = "UI manual replay";
-                        if (r.force) {
-                            opts.force = true;
-                            opts.reason += " (forced)";
+                            $scope.actions.workflowReplays.push({targetId: 'start', reason: 'Restart workflow from UI',
+                                label: 'Restart '+(stepIndex>=0 ? 'workflow ' : '')+reason});
                         }
-                        r.action = () => {
-                            entityApi.replay(applicationId, entityId, $scope.workflowId. targetId, opts);
-                        };
-                    });
+
+                        if (!replayableFromStart) {
+                            $scope.actions.workflowReplays.push({targetId: 'start', reason: 'Force restart from UI',
+                                label: 'Force restart', force: true});
+                        }
+                        // force replays
+                        $scope.actions.workflowReplays.forEach(r => {
+                            // could prompt for a reason
+                            r.action = () => {
+                                const opts = {};
+                                opts.reason = r.reason;
+                                if (r.force) opts.force = true;
+                                entityApi.replayWorkflow(applicationId, entityId, $scope.workflowId, r.targetId, opts)
+                                    .then(response => {
+                                        $state.go('main.inspect.activities.detail', {
+                                            applicationId: applicationId,
+                                            entityId: entityId,
+                                            activityId: response.data,
+                                        });
+                                    });
+                            };
+                        });
+                    }
+                    if (!$scope.actions.workflowReplays.length) delete $scope.actions['workflowReplays'];
+
+                    onActivityOrWorkflowUpdate();
                 }
-                if (!$scope.actions.workflowReplays.length) delete $scope.actions['workflowReplays'];
+
+                processWorkflowData(wResponse);
 
                 if (vm.model.workflow.data.status === 'RUNNING') wResponse.interval(1000);
-                observers.push(wResponse.subscribe((wResponse2)=> {
-                    // change the workflow object so widgets get refreshed
-                    vm.model.workflow = { ...vm.model.workflow, data: wResponse2.data };
-                }));
+                observers.push(wResponse.subscribe(processWorkflowData));
 
             }).catch(error => {
                 if (optimistic) {
                     vm.model.workflow.loading = null;
                     throw error;
                 }
+                console.log("Unable to load workflow", $scope.workflowId, error);
 
-                console.log("ERROR loading workflow " + $scope.workflowId, error);
                 vm.model.workflow.loading = 'error';
             });
         };
@@ -161,11 +231,6 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
                 }
             }
 
-            delete $scope.actions['cancel'];
-            if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<=0) {
-                $scope.actions.cancel = { doAction: () => { activityApi.cancelActivity(activityId); } };
-            }
-
             $scope.workflowId = null;  // if the task loads, force the workflow id to be found on it, otherwise ignore it
             if ((vm.model.activity.tags || []).find(t => t=="WORKFLOW")) {
                 const workflowTag = getTaskWorkflowTag(vm.model.activity);
@@ -175,13 +240,17 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
                 }
             }
 
-            vm.error = undefined;
-            if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<0) response.interval(1000);
-            observers.push(response.subscribe((response)=> {
+            function saveActivity(response) {
                 vm.model.activity = response.data;
+                onActivityOrWorkflowUpdate();
                 vm.error = undefined;
                 vm.errorBasic = false;
-            }));
+            }
+
+            saveActivity(response);
+
+            if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<0) response.interval(1000);
+            observers.push(response.subscribe(saveActivity));
 
         }).catch((error)=> {
             $log.warn('Error loading activity for '+activityId, error);
@@ -191,25 +260,9 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
                 'The task is no longer stored in memory. Details may be available in logs.');
 
             // in case it corresponds to a workflow and not a task, try loading as a workflow
-            function onNonTaskWorkflowLoad() {
-                const wft = (vm.model.workflow.data.replays || []).find(t => t.taskId === activityId);
-                if (wft) {
-                    vm.model.activity = makeTaskStubFromWorkflowRecord(vm.model.workflow.data, wft);
-                    vm.model.workflow.tag = getTaskWorkflowTag(vm.model.activity);
-                } else {
-                    throw "Workflow task "+activityId+" not stored on workflow";
-                }
-
-                // give a better error
-                vm.error = $sce.trustAsHtml('Limited information on workflow task <b>' + _.escape(activityId) + '</b>.<br/><br/>' +
-                    (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc==-1
-                        ? "The run appears to have been interrupted by a server restart or failover."
-                        : 'The workflow is known but this task is no longer stored in memory.') );
-            }
-
-            loadWorkflow({workflowId: activityId}).then(onNonTaskWorkflowLoad)
+            loadWorkflow({workflowId: activityId}, { nonTask: true })
                 .catch(error => {
-                    loadWorkflow(null).then(onNonTaskWorkflowLoad)
+                    loadWorkflow(null, { nonTask: true })
                         .catch(error => {
                             $log.debug("ID "+activityId+"/"+$scope.workflowId+" does not correspond to workflow either", error);
                         });
@@ -235,7 +288,8 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
         activityApi.activityDescendants(activityId, 8, true).then((response)=> {
             vm.model.activitiesDeep = response.data;
             vm.error = undefined;
-            // TODO would be nice to subscribe more often, e.g. every second
+
+            if (!vm.model.activity.endTimeUtc || vm.model.activity.endTimeUtc<0) response.interval(1000);
             observers.push(response.subscribe((response)=> {
                 vm.model.activitiesDeep = response.data;
                 if (!vm.errorBasic) {
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 0c01da1d..f72d2922 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
@@ -219,6 +219,23 @@
             }
         }
     }
+
+    .workflow-buttons {
+        float: right;
+        margin-top: -9px;
+        button {
+            //min-width: 15em;
+            //text-align: left;
+        }
+    }
+
+    div.workflow-steps {
+        margin-top: 24px;
+    }
+    div.workflow-preface-para {
+        margin-top: 12px;
+        margin-bottom: 24px;
+    }
 }
 
 .dropdown-at-root {
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 08b4b889..3916a9eb 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
@@ -51,14 +51,14 @@
                     </br-button>
                     <ul uib-dropdown-menu-nested class="dropdown-at-root dropdown-menu-right">
 
-                        <li><a href="" ng-if="actions.cancel" ng-click="actions.cancel.doAction()">Cancel</a></li>
+                        <li><a href="" ng-if="actions.cancel" ng-click="actions.cancel.doAction()">{{actions.cancel.label}}</a></li>
 
                         <li ng-if="actions.workflowReplays" uib-dropdown-nested dropdown-append-to-body="true">
 
                             <a href="" uib-dropdown-toggle-nested>Replay workflow <span class="caret"></span></a>
                             <ul class="dropdown-submenu-left dropdown-at-root dropdown-menu-right" uib-dropdown-menu-nested>
                                 <li ng-repeat="replay in actions.workflowReplays track by $index" id="replay-{{replay.targetId}}">
-                                    <a class="dropdown-item" href="" ng-click="replay.action()">{{ replay.targetName }}</a>
+                                    <a class="dropdown-item" href="" ng-click="replay.action()">{{ replay.label }}</a>
                                 </li>
                             </ul>
 
@@ -187,17 +187,17 @@
                         <div class="workflow-body">
                             <div ng-if="vm.model.workflow.loading == 'loaded'">
                                 <div ng-if="vm.model.workflow.data.replays.length > 1">
-                                    <div style="float: right; margin-top: -9px;" class="btn-group" uib-dropdown>
+                                    <div  class="workflow-buttons btn-group" uib-dropdown>
                                         <button id="replay-button" type="button" class="btn btn-select-dropdown" uib-dropdown-toggle>
                                             Select replay <span class="caret"></span>
                                         </button>
                                         <ul class="dropdown-menu dropdown-menu-right dropdown-menu-replays" uib-dropdown-menu role="menu" aria-labelledby="replay-button">
-                                            <li role="menuitem" ng-repeat="replay in vm.model.workflow.data.replays" id="workflow-replay-{{ replay.taskId }}">
+                                            <li role="menuitem" ng-repeat="replay in vm.model.workflow.data.replays.slice().reverse()" id="workflow-replay-{{ replay.taskId }}">
                                                 <a href="" ui-sref="main.inspect.activities.detail({activityId: replay.taskId, workflowId: workflowId})" ng-class="{'selected' : vm.model.activityId === replay.taskId}">
                                                     <i class="fa fa-check check if-selected"></i>
 <!--                                                    <span class="monospace">{{ replay.taskId }}</span>-->
                                                     <span ng-if="replay.reasonForReplay">{{ replay.reasonForReplay }} (</span
-                                                    ><span>{{ replay.submitTimeUtc | dateFilter: 'short' }}</span
+                                                    ><span ng-if="replay.submitTimeUtc>0">{{ replay.submitTimeUtc | dateFilter: 'short' }}</span
                                                     ><span ng-if="replay.reasonForReplay">)</span>
 
                                                 </a> </li>
@@ -214,6 +214,10 @@
                                     This task is
                                     <span ng-if="!vm.isNullish(vm.model.workflow.tag.stepIndex)">for step <b>{{ vm.model.workflow.tag.stepIndex+1 }}</b>
                                         in
+                                        <span ng-if="vm.model.workflow.runReplayId">
+                                            <span ng-if="vm.model.workflow.runIsOld">a previous run of </span>
+                                            <span ng-if="vm.model.workflow.runIsLatest">the most recent run of </span>
+                                        </span>
                                         <a ui-sref="main.inspect.activities.detail({entityId: vm.model.entityId, activityId: vm.model.workflow.data.taskId, workflowId})">
                                             workflow <span class="monospace">{{vm.model.workflow.data.workflowId}}</span>:
                                             <b>{{vm.model.workflow.data.name}}</b></a>.
@@ -225,18 +229,53 @@
                                     </span>
                                     <span ng-if="vm.isNullish(vm.model.workflow.tag.stepIndex) && vm.model.activity.id">
                                         for
-                                        <span ng-if="vm.model.workflow.data.taskIds.length>1">
-                                            <span ng-if="vm.model.workflow.data.taskIds[vm.model.workflow.data.taskIds.length-1] === vm.model.activityId">
-                                                the most recent </span>
-                                            <span ng-if="vm.model.workflow.data.taskIds[vm.model.workflow.data.taskIds.length-1] !== vm.model.activityId">
-                                                run {{vm.model.workflow.data.taskIds.indexOf(vm.model.activityId)+1}} </span>
-                                            of {{ vm.model.workflow.data.taskIds.length }} of
+                                        <span ng-if="vm.model.workflow.runIsOld" style="font-weight: bold;">
+                                            a previous run of
+                                            <a ui-sref="main.inspect.activities.detail({entityId: vm.model.entityId, activityId: vm.model.workflow.data.taskId, workflowId})">
+                                                    workflow <span class="monospace">{{vm.model.workflow.data.workflowId}}</span>:
+                                                <b>{{vm.model.workflow.data.name}}</b>.</a>
+                                        </span>
+                                        <span ng-if="!vm.model.workflow.runIsOld">
+                                            <span ng-if="vm.model.workflow.runIsLatest">the most recent run of </span>
+                                            workflow <span class="monospace">{{vm.model.workflow.data.workflowId}}</span>:
+                                            <b>{{vm.model.workflow.data.name}}</b>.
                                         </span>
-                                        workflow <span class="monospace">{{vm.model.workflow.data.workflowId}}</span>:
-                                        <b>{{vm.model.workflow.data.name}}</b>.
                                     </span>
                                 </div>
-                                <div ng-if="showReplayHelp" style="margin-top: 12px; margin-bottom: 24px;">
+
+                                <div ng-if="vm.model.workflow.data.parentId" class="workflow-preface-para">
+                                    This is a sub-workflow in
+                                    <b><a ui-sref="main.inspect.activities.detail({entityId: vm.model.entityId, activityId: vm.model.workflow.data.parentId, workflowId: vm.model.workflow.data.parentId, workflowLatestRun: true})">
+                                        workflow <span class="monospace">{{vm.model.workflow.data.parentId}}</span
+                                    ></a></b>.
+                                </div>
+
+                                <div ng-if="vm.model.workflow.runIsOld" class="workflow-preface-para">
+                                    For previous runs, the subtask view
+                                    <span ng-if="!vm.isNullish(vm.model.workflow.tag.stepIndex)">for
+                                        <a ui-sref="main.inspect.activities.detail({entityId: vm.model.entityId, activityId: vm.model.workflow.runReplayId, workflowId})">
+                                            the relevant run
+                                        </a>
+                                    </span>
+                                    <span ng-if="vm.isNullish(vm.model.workflow.tag.stepIndex)">
+                                        <span ng-if="showOldWorkflowRunStepDetails">further</span>
+                                        below
+                                    </span>
+                                    is normally more informative than workflow steps.
+                                    <span ng-if="!showOldWorkflowRunStepDetails">
+                                        However the workflow step view
+                                        <a href="" ng-click="vm.toggleOldWorkflowRunStepDetails()">
+                                            can be shown</a>
+                                        below if desired.
+                                    </span>
+                                    <span ng-if="showOldWorkflowRunStepDetails">
+                                        The workflow step view immediately below
+                                        <a href="" ng-click="vm.toggleOldWorkflowRunStepDetails()">
+                                            can be hidden</a>.
+                                    </span>
+                                </div>
+
+                                <div ng-if="showReplayHelp" class="workflow-preface-para">
                                     Workflows can be replayed in certain situations, such as if they fail or the server is restarted.
                                     This workflow invocation instance has been replayed, with a total of {{ vm.model.workflow.data.taskIds.length }} runs.
                                     Individual replays can be viewed by selecting a task ID from the dropdown.
@@ -245,7 +284,7 @@
                                     Sub-task and log views further below can be useful to disambiguate multiple replays if required.
                                 </div>
 
-                                <workflow-steps workflow="vm.model.workflow" task="vm.model.activity"></workflow-steps>
+                                <workflow-steps workflow="vm.model.workflow" task="vm.model.activity" ng-if="!vm.model.workflow.runIsOld || showOldWorkflowRunStepDetails"></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>
diff --git a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/dropdown-nested.js b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/dropdown-nested.js
index 00680a61..93ec9d95 100644
--- a/ui-modules/app-inspector/app/views/main/inspect/activities/detail/dropdown-nested.js
+++ b/ui-modules/app-inspector/app/views/main/inspect/activities/detail/dropdown-nested.js
@@ -67,6 +67,12 @@ angular.module(MODULE_NAME, ['ui.bootstrap.multiMap', 'ui.bootstrap.position'])
             }
         };
 
+        function containsNested(container, target) {
+            if (!container || !target || !container[0] || !target[0]) return false;
+            if (container[0]==target[0]) return true;
+            return containsNested(container, angular.element(target.parent()));
+        }
+
         this.close = function(dropdownScope, element, appendTo) {
             if (openScope === dropdownScope) {
                 openScope = null;
@@ -79,6 +85,8 @@ angular.module(MODULE_NAME, ['ui.bootstrap.multiMap', 'ui.bootstrap.position'])
                 $document.off('click', closeDropdown);
                 $document.off('keydown', this.keybindFilter);
             }
+            [openScope, oldOpenScopes].filter(candidateChild => candidateChild && candidateChild.getToggleElement && containsNested(dropdownScope.getDropdownElement(), candidateChild.getToggleElement()))
+                .forEach(containedChild => containedChild.isOpen = false);
 
             if (!appendTo) {
                 return;
@@ -104,52 +112,43 @@ angular.module(MODULE_NAME, ['ui.bootstrap.multiMap', 'ui.bootstrap.position'])
             // unbound this event handler. So check openScope before proceeding.
             let scopesToApply = [];
 
-            function containsNested(container, target) {
-                if (!container) return false;
-                if (container==target) return true;
-                if (container[0] && container[0].contains && container[0].contains(target)) return true;
-                if (container.contains && container.contains(target)) return true;
-
-                let kids = angular.element(container).children();
-                if (kids && kids.length) {
-                    for (let i=0; i<kids.length; i++) {
-                        let found = containsNested(kids[i], target);
-                        if (found) return true;
-                    }
-                }
-                return false;
+            function isAnyTrigger($element) {
+                return $element.hasClass('dropdown-toggle');
             }
 
-            function isAnyTrigger(element) {
-                return element.hasClass('dropdown-toggle');
+            function isAnyAncestor($element, test) {
+                if (!$element || !$element[0]) return false;
+                if (test($element)) {
+                    return true;
+                }
+                return isAnyAncestor(angular.element($element.parent()), test);
             }
 
             function closeIfApplicable(scope) {
-                if (evt && scope.getAutoClose() === 'disabled') {
-                    return;
-                }
+                if (evt) {
+                    if (scope.getAutoClose() === 'disabled') {
+                        return;
+                    }
 
-                if (evt && evt.which === 3) {
-                    return;
-                }
+                    if (evt.which === 3) {
+                        return;
+                    }
 
-                if (evt &&
-                    isAnyTrigger(angular.element(evt.target))) {
-                    return;
-                }
-                // could do "is contained in any trigger"; but doesn't seem needed yet
+                    if (isAnyAncestor(angular.element(evt.target), isAnyTrigger)) {
+                        return;
+                    }
 
-                var toggleElement = scope.getToggleElement();
-                if (evt && toggleElement && containsNested(toggleElement, evt.target)) {
-                    return;
-                }
+                    var toggleElement = scope.getToggleElement();
+                    if (toggleElement && containsNested(angular.element(toggleElement), angular.element(evt.target))) {
+                        return;
+                    }
 
-                var dropdownElement = scope.getDropdownElement();
-                if (evt &&
-                    scope.getAutoClose() === 'outsideClick' &&
-                    dropdownElement && containsNested(dropdownElement, evt.target)) {
-                    return;
+                    if (scope.getAutoClose() === 'outsideClick' &&
+                        containsNested(angular.element(scope.getDropdownElement()), angular.element(evt.target))) {
+                        return;
+                    }
                 }
+
                 scope.isOpen = false;
                 scopesToApply.push(scope);
 
diff --git a/ui-modules/utils/providers/api-observer-interceptor.provider.js b/ui-modules/utils/providers/api-observer-interceptor.provider.js
index a5ab9f4b..df8d21f8 100644
--- a/ui-modules/utils/providers/api-observer-interceptor.provider.js
+++ b/ui-modules/utils/providers/api-observer-interceptor.provider.js
@@ -47,7 +47,7 @@ export function apiObserverInterceptorProvider() {
             };
 
             function doDriveBy(response, error = false) {
-                if (response.config.hasOwnProperty(OBSERVABLE) && response.config[OBSERVABLE]) {
+                if ((response.config || {}).hasOwnProperty(OBSERVABLE) && response.config[OBSERVABLE]) {
                     response.clock = clock;
                     response.interval = (interval) => {
                         response.clock = Observable.interval(interval);