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:22 UTC
[brooklyn-ui] 13/24: richer dropdowns on tasks list, filter by workflow
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 c4e545f11e76b9f3f515db84eac07a3234e7e78d
Author: Alex Heneveld <al...@cloudsoft.io>
AuthorDate: Fri Oct 7 13:50:26 2022 +0100
richer dropdowns on tasks list, filter by workflow
much better approach to initializing the dropdowns for task list view
---
.../components/task-list/task-list.directive.js | 532 +++++++++++++++++----
.../app/components/task-list/task-list.less | 97 +++-
.../components/task-list/task-list.template.html | 50 +-
.../components/workflow/workflow-step.directive.js | 6 +
.../workflow/workflow-step.template.html | 11 +-
.../inspect/activities/activities.controller.js | 68 +--
.../inspect/activities/detail/detail.controller.js | 16 +-
.../main/inspect/activities/detail/detail.less | 5 +-
.../inspect/activities/detail/detail.template.html | 11 +-
9 files changed, 644 insertions(+), 152 deletions(-)
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 35fc8b03..e890a283 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
@@ -29,7 +29,6 @@ angular.module(MODULE_NAME, [])
.filter('timeAgoFilter', timeAgoFilter)
.filter('dateFilter', dateFilter)
.filter('durationFilter', durationFilter)
- .filter('activityTagFilter', activityTagFilter)
.filter('activityFilter', ['$filter', activityFilter]);
export default MODULE_NAME;
@@ -54,34 +53,140 @@ export function taskListDirective() {
// transient set when those tags seen
};
- setFiltersForTasks($scope, isActivityChildren);
- $scope.filterValue = $scope.search;
-
+ $scope.isEmpty = x => _.isNil(x) || x.length==0 || (typeof x === "object" && Object.keys(x).length==0);
+ $scope.filters = { available: {}, selectedFilters: {}, selectedIds: {} };
$scope.model = {
appendTo: $element,
filterResult: null,
- filterByTag: isActivityChildren ? $scope.filters['_top']
- : $scope.taskType === 'activity' ? $scope.filters['_effectorsTop']
- : $scope.filters[$scope.taskType || '_effectorsTop'],
};
+ $scope.tasksFilteredByTag = [];
+
+ $scope.findTasksExcludingCategory = (tasks, selected, categoryToExclude) => {
+ let result = tasks || [];
+
+ if (selected) {
+ _.uniq(Object.values(selected).map(f => f.category)).forEach(category => {
+ if (categoryToExclude === '' || categoryToExclude != category) {
+ let newResult = [];
+ if ($scope.filters.startingSetFilterForCategory[category]) {
+ newResult = $scope.filters.startingSetFilterForCategory[category](result);
+ }
+ Object.values(selected).filter(f => f.category === category).forEach(f => {
+ const filter = f.filter;
+ if (!filter) {
+ console.warn("Incomplete activities tag filter", tagF);
+ } else {
+ newResult = newResult.concat(filter(result));
+ }
+ });
+
+ // limit result, but preserving order
+ newResult = newResult.map(t => t.id);
+ result = result.filter(t => newResult.includes(t.id));
+ }
+ })
+ }
+ return result;
+ };
+ $scope.recomputeTasks = () => {
+ $scope.tasksFilteredByTag = $scope.findTasksExcludingCategory(
+ tasksAfterGlobalFilters($scope.tasks, $scope.globalFilters),
+ $scope.filters.selectedFilters, '');
+
+ // do this to update the counts
+ setFiltersForTasks($scope, isActivityChildren);
+
+ // now update name
+ const enabledCategories = _.uniq(Object.values($scope.filters.selectedFilters).map(f => f.category));
+ let filterNameParts = Object.entries($scope.filters.displayNameForCategory).map(([category, nameFn]) => {
+ if (!enabledCategories.includes(category)) return null;
+ let nf = $scope.filters.displayNameForCategory[category];
+ return nf ? nf(Object.values($scope.filters.selectedFilters).filter(f => f.category === category)) : null;
+ }).filter(x => x);
+ $scope.filters.selectedDisplayName = filterNameParts.length ? filterNameParts.join('; ') :
+ isActivityChildren ? 'all sub-tasks' : 'all tasks';
+ };
+
+ function selectFilter(filterId, state) {
+ // annoying, but since task list is live updated, we store the last value of selectedIds in the event filters come and go;
+ // mainly tried because initial order could be too strange, but now we correct that, so this isn't so important
+ let oldTheoreticalEnablement = $scope.filters.selectedIds[filterId];
+
+ const f = $scope.filters.available[filterId];
+ if (!f) {
+ console.log("FILTER "+filterId+" not available yet, storing theoretical enablement");
- const activityTagFilterApplication = () => activityTagFilter()($scope.tasks, [$scope.model.filterByTag, $scope.globalFilters]);
- if ((!$scope.taskType || $scope.taskType.startsWith('activity')) && (!$scope.model.filterByTag || activityTagFilterApplication().length==0 )) {
- // show all if default view is empty, unless explicit tag was requested
- $scope.model.filterByTag = $scope.filters['_top'];
- if (!$scope.model.filterByTag || activityTagFilterApplication().length == 0 ) {
- $scope.model.filterByTag = $scope.filters['_recursive'] || $scope.model.filterByTag;
+ if (!_.isNil(state) ? state : !oldTheoreticalEnablement) {
+ $scope.filters.selectedIds[filterId] = 'theoretically-enabled';
+ } else {
+ delete $scope.filters.selectedIds[filterId];
+ }
+
+ // we tried to select eg effector, when it didn't exist
+ return false;
+ } else {
+ f.select(filterId, f, state);
+ return true;
}
}
+
+ setFiltersForTasks($scope, isActivityChildren);
+ $scope.filterValue = $scope.search;
+
+ selectFilter("_top", true);
+ selectFilter("_anyTypeTag", true);
+ if ($scope.taskType === 'activity') {
+ // default?
+ selectFilter('EFFECTOR');
+ selectFilter('WORKFLOW');
+ } else if ($scope.taskType) {
+ selectFilter($scope.taskType);
+ } else {
+ // TODO when is this called?
+ selectFilter('EFFECTOR');
+ selectFilter('WORKFLOW');
+ }
+
+ cacheSelectedIdsFromFilters($scope);
+ selectFilter("_workflowReplayed");
+ selectFilter("_workflowNonLastReplayHidden");
+
+ console.log($scope.filters);
+
+ // // this would be nice, but it doesn't play nice with dynamic task updates
+ // // sometimes no tasks are loaded yet and this enables the "all" but then tasks get loaded
+ // if ($scope.tasksFilteredByTag.length==0) {
+ // // if nothing found at top level then broaden
+ // selectFilter("_top", false);
+ // }
+
+
+
+ // TODO check taskType=activity... .... can they not all just leave it off, to send the default; send the default?
+ // and make sure others send EFFECTOR
+
+ // if ((!$scope.taskType || $scope.taskType.startsWith('activity')) && (!filterPreselected || $scope.tasksFilteredByTag.length==0 )) {
+ // // if nothing found with filters, try disabling the filters
+ // filterPreselected = selectFilter('_top', false);
+ // if (!filterPreselected || $scope.tasksFilteredByTag.length == 0 ) {
+ // selectFilter('_top', true);
+ // }
+ // }
+
$scope.isScheduled = isScheduled;
$scope.$watch('tasks', ()=>{
- setFiltersForTasks($scope, isActivityChildren);
+ $scope.recomputeTasks();
});
+ $scope.$watch('globalFilters', ()=>{
+ $scope.recomputeTasks();
+ });
+
$scope.getTaskDuration = function(task) {
if (!task.startTimeUtc) {
return null;
}
+ if (!_.isNil(task.endTimeUtc) && task.endTimeUtc <= 0) return null;
return (task.endTimeUtc === null ? new Date().getTime() : task.endTimeUtc) - task.startTimeUtc;
}
@@ -93,21 +198,8 @@ export function taskListDirective() {
if (tag) return tag.workflowId;
return null;
};
- }
- function tagReducer(result, tag) {
- if (typeof tag === 'string') {
- if (result.hasOwnProperty(tag)) {
- result[tag].count ++;
- } else {
- result[tag] = {
- display: 'Tag: '+tag.toLowerCase(),
- tag,
- count: 1,
- }
- }
- }
- return result;
+ $scope.recomputeTasks();
}
function setFiltersForTasks(scope, isActivityChildren) {
@@ -116,7 +208,7 @@ export function taskListDirective() {
// include a toggle for transient tasks
if (!globalFilters.transient) {
- const numTransient = tasksWithTag(tasksAll, 'TRANSIENT').length;
+ const numTransient = filterForTasksWithTag('TRANSIENT')(tasksAll).length;
if (numTransient>0 && numTransient<tasksAll.length) {
// only default to filtering transient if some but not all are transient
globalFilters.transient = {
@@ -132,49 +224,323 @@ export function taskListDirective() {
}
const tasks = tasksAfterGlobalFilters(tasksAll, globalFilters);
- const tops = topLevelTasks(tasks);
- let defaultTags = {};
- defaultTags['_top'] = {
- display: 'All top-level tasks',
- filter: topLevelTasks,
- count: tops.length,
+ function defaultToggleFilter(tag, value, forceValue, fromUi, skipRecompute) {
+ if ((scope.filters.selectedIds[tag] && _.isNil(forceValue)) || forceValue===false) {
+ delete scope.filters.selectedIds[tag];
+ delete scope.filters.selectedFilters[tag];
+ if (value.onDisabledPost) value.onDisabledPost(tag, value, forceValue);
+ } else {
+ if (value.onEnabledPre) value.onEnabledPre(tag, value, forceValue);
+ scope.filters.selectedIds[tag] = 'enabled';
+ scope.filters.selectedFilters[tag] = value;
+ }
+ if (fromUi) {
+ // on a UI click, don't try to be too clever about remembered IDs
+ cacheSelectedIdsFromFilters(scope);
+ }
+
+ if (!skipRecompute) scope.recomputeTasks();
}
- if (tasks.length > tops.length) {
- defaultTags['_recursive'] = {
- display: 'All tasks (recursive)',
- filter: input => input,
- count: tasks.length,
+
+ /*
+ MENU should look like following, with group-specific behaviour for filtering and enablement,
+ e.g. auto-enable all of first group if only is de-selected:
+
+ Only show top-level tasks
+ x Show tasks called from other entities
+ submittedByTask==null ||
+ submittedByTask.metadata.entityId != entityId
+ Show tasks nested within this entity
+ ---
+ Any task type/tag
+ x Effector calls
+ x Workflows
+ Tag: tag1
+ Tag: tag2
+
+
+TODO workflow ui
+ ? most recent run of workflow only
+ combine others under last if loaded
+ ? show individual workflows resumed on startup! ? label as top-level?
+ */
+
+ function clearCategory(category) {
+ return function(filterId, filter, forceValue) {
+ Object.entries(scope.filters.selectedFilters).forEach( ([k,v])=> {
+ if (v.category === (category || filter.category)) {
+ delete scope.filters.selectedFilters[k];
+ }
+ });
}
}
- defaultTags['_effectorsTop'] = {
- display: 'Effectors (top-level)',
- filter: tt => tasksWithTag(topLevelTasks(tt), 'EFFECTOR'),
- count: tasksWithTag(tops, 'EFFECTOR').length,
+ function clearOther(idToClear) {
+ return function(filterId, filter, forceValue) {
+ delete scope.filters.selectedFilters[idToClear];
+ }
}
- defaultTags['EFFECTOR'] = {
- display: 'Effectors (recursive)',
- tag: 'EFFECTOR',
- count: 0,
+ function enableFilterIfCategoryEmpty(idToEnable, category) {
+ return function(filterId, filter, forceValue) {
+ if (!Object.values(scope.filters.selectedFilters).find(f => f.category === (category||filter.category))) {
+ // empty
+ const other = scope.filters.available[idToEnable || filterId];
+ if (other) scope.filters.selectedFilters[idToEnable || filterId] = other;
+ }
+ }
}
- if (isActivityChildren) {
- defaultTags['_top'].display = 'Direct sub-tasks';
- if (defaultTags['_recursive']) defaultTags['_recursive'].display = 'All sub-tasks (recursive)';
+ function enableOthersIfCategoryEmpty(idToLeaveDisabled, category) {
+ return function(filterId, filter, forceValue) {
+ if (!Object.values(scope.filters.selectedFilters).find(f => f.category === (category||filter.category))) {
+ // empty
+ Object.entries(scope.filters.available).forEach( ([k,f]) => {
+ if (f.category === (category||filter.category) && k !== (idToLeaveDisabled || filterId)) {
+ scope.filters.selectedFilters[k] = f;
+ }
+ });
+ }
+ }
}
- const result = tasks.reduce((result, subTask)=> {
- return subTask.tags.reduce(tagReducer, result);
- }, defaultTags);
+ const defaultFilters = {};
- // could suppress if no effectors
- // if (!result['_effectorsTop'].count) {
- // delete result['_effectorsTop'];
- // if (!result['EFFECTOR'].count) {
- // delete result['EFFECTOR'];
- // }
- // }
+ let tasksById = tasksAll.reduce( (result,t) => { result[t.id] = t; return result; }, {} );
+ function filterTopLevelTasks(tasks) { return filterWithId(tasks, tasksById, isTopLevelTask); }
+ function filterNonTopLevelTasks(tasks) { return filterWithId(tasks, tasksById, isNonTopLevelTask); }
+ function filterCrossEntityTasks(tasks) { return filterWithId(tasks, tasksById, isCrossEntityTask); }
+ function filterNestedSameEntityTasks(tasks) { return filterWithId(tasks, tasksById, isNestedSameEntityTask); }
- scope.filters = result; //previously we extended, but now allow to clear
+ scope.filters.startingSetFilterForCategory = {
+ nested: filterTopLevelTasks,
+ };
+ function getFilterOrEmpty(id) {
+ return id && (id.filter ? id : scope.filters.available[id]) || {};
+ }
+ scope.filters.displayNameForCategory = {
+ nested: set => {
+ if (!set || !set.length) return null;
+ let nestedFiltersAvailable = Object.values(scope.filters.available).filter(f => f.category === 'nested');
+ if (set.length == nestedFiltersAvailable.length-1 && !set[0].isDefault) {
+ // everything but first is selected, so no message
+ return null;
+ }
+ if (set.length==1) {
+ return getFilterOrEmpty(set[0]).displaySummary;
+ }
+ // all tasks
+ return null;
+ },
+ 'type/tag': set => {
+ if (!set || !set.length) return null;
+ if (set.length<=3) {
+ let tags = set.map(s => (getFilterOrEmpty(s).displaySummary || '').toLowerCase()).filter(x => x);
+ if (tags.length==0) return null;
+ if (tags.length==1) return tags[0];
+ if (tags.length==2) return tags[0] + ' or ' + tags[1];
+ if (tags.length==3) return tags[0] + ', ' + tags[1] + ', or ' + tags[2];
+ }
+ return 'any of multiple tags'
+ },
+ };
+ defaultFilters['_top'] = {
+ display: 'Only show ' + (isActivityChildren ? 'direct sub-tasks' : 'top-level tasks'),
+ displaySummary: 'only top-level tasks',
+ isDefault: true,
+ filter: filterTopLevelTasks, // redundant with starting set, but contributes the right count
+ category: 'nested',
+ onEnabledPre: clearCategory(),
+ onDisabledPost: enableOthersIfCategoryEmpty('_top'),
+ }
+ if (!isActivityChildren) {
+ defaultFilters['_cross_entity'] = {
+ display: 'Include cross-entity sub-tasks',
+ displaySummary: 'cross-entity tasks',
+ filter: filterCrossEntityTasks,
+ category: 'nested',
+ onEnabledPre: clearOther('_top'),
+ onDisabledPost: enableFilterIfCategoryEmpty('_top'),
+ }
+ defaultFilters['_recursive'] = {
+ display: 'Include sub-tasks on this entity',
+ displaySummary: 'sub-tasks',
+ filter: filterNestedSameEntityTasks,
+ category: 'nested',
+ onEnabledPre: clearOther('_top'),
+ onDisabledPost: enableFilterIfCategoryEmpty('_top'),
+ }
+ } else {
+ defaultFilters['_recursive'] = {
+ display: 'Show all sub-tasks',
+ displaySummary: 'sub-tasks',
+ filter: filterNonTopLevelTasks,
+ category: 'nested',
+ onEnabledPre: clearOther('_top'),
+ onDisabledPost: enableFilterIfCategoryEmpty('_top'),
+ }
+ }
+
+ const countWorkflowsWhichAreNestedButHaveReplayed = tasksAll.filter(t =>
+ t.isReplayedWorkflowLatest && t.submittedByTask
+ ).length;
+ defaultFilters['_workflowReplayed'] = {
+ display: 'Include workflow sub-tasks which are replayed',
+ displaySummary: null,
+ filter: tasks => tasks.filter(t => t.isReplayedWorkflowLatest && t.submittedByTask),
+ category: 'nested',
+ count: countWorkflowsWhichAreNestedButHaveReplayed,
+ countAbsolute: countWorkflowsWhichAreNestedButHaveReplayed,
+ onEnabledPre: clearCategory(),
+ onDisabledPost: enableOthersIfCategoryEmpty('_anyTypeTag'),
+ }
+
+ const countWorkflowsWhichArePreviousReplays = tasksAll.filter(t => t.isWorkflowPreviousRun).length;
+ defaultFilters['_workflowNonLastReplayHidden'] = {
+ display: 'Exclude old runs of workflows',
+ help: 'Some workflows have been replayed, either manually or on a server restart or failover. ' +
+ 'To simplify the display, old runs of workflow invocations which have been replayed are excluded here by default. ' +
+ 'The most recent replay will be included, subject to other filters, and previous replays can be accessed ' +
+ 'on the workflow page.',
+ displaySummary: null,
+ filter: tasks => tasks.filter(t => {
+ return _.isNil(t.isWorkflowPreviousRun) || !t.isWorkflowPreviousRun;
+ }),
+ count: countWorkflowsWhichArePreviousReplays,
+ countAbsolute: countWorkflowsWhichArePreviousReplays,
+ category: 'workflow',
+ onEnabledPre: null,
+ onDisabledPost: null,
+ }
+
+ const countWorkflowsWithoutTaskWhichAreCompleted = tasksAll.filter(t => t.endTimeUtc>0 && t.isTaskStubFromWorkflowRecord).length;
+ defaultFilters['_workflowCompletedWithoutTaskHidden'] = {
+ display: 'Exclude old completed workflows',
+ help: 'Some older workflows no longer have a task record, '+
+ 'either because they completed in a previous server prior to a server restart or failover, ' +
+ 'or because their tasks have been cleared from memory in this server. ' +
+ 'These can be excluded to focus on more recent tasks.',
+ displaySummary: null,
+ // filter: tasks => tasks.filter(t => t.isWorkflowPreviousRun !== false),
+ filter: tasks => tasks.filter(t => !(t.endTimeUtc>0 && t.isTaskStubFromWorkflowRecord)),
+ count: countWorkflowsWithoutTaskWhichAreCompleted,
+ countAbsolute: countWorkflowsWithoutTaskWhichAreCompleted,
+ category: 'workflow2',
+ onEnabledPre: null,
+ onDisabledPost: null,
+ }
+
+ defaultFilters['_anyTypeTag'] = {
+ display: 'Any task type or tag',
+ displaySummary: null,
+ filter: input => input,
+ category: 'type/tag',
+ onEnabledPre: clearCategory(),
+ onDisabledPost: enableOthersIfCategoryEmpty('_anyTypeTag'),
+ }
+
+ function addTagFilter(tag, target, display, displaySummary) {
+ if (!target[tag]) target[tag] = {
+ display: display,
+ displaySummary: displaySummary || tag.toLowerCase(),
+ filter: filterForTasksWithTag(tag),
+ category: 'type/tag',
+ onEnabledPre: clearOther('_anyTypeTag'),
+ onDisabledPost: enableFilterIfCategoryEmpty('_anyTypeTag'),
+ }
+ }
+ // put these first
+ addTagFilter('EFFECTOR', defaultFilters, 'Effectors', 'effector');
+ addTagFilter('WORKFLOW', defaultFilters, 'Workflow');
+
+ const filtersIncludingTags = {...defaultFilters};
+
+ // add filters for other tags
+ tasks.forEach(t =>
+ (t.tags || []).filter(tag => typeof tag === 'string' && tag.length < 32).forEach(tag =>
+ addTagFilter(tag, filtersIncludingTags, 'Tag: ' + tag.toLowerCase())
+ ));
+
+ // fill in fields
+
+ Object.entries(filtersIncludingTags).forEach(([k, f]) => {
+ if (!f.select) f.select = defaultToggleFilter;
+ if (!f.onClick) f.onClick = (filterId, filter) => defaultToggleFilter(filterId, filter, null, true);
+
+ if (_.isNil(f.count)) f.count = scope.findTasksExcludingCategory(f.filter(tasks), scope.filters.selectedFilters, f.category).length;
+ if (_.isNil(f.countAbsolute)) f.countAbsolute = f.filter(tasks).length;
+ });
+
+ function updateSelectedFilters(newValues) {
+ const deferredCalls = [];
+ Object.entries(scope.filters.selectedIds).forEach(([filterId,filterSelectionNote]) => {
+ const newValue = newValues[filterId];
+ const oldValue = scope.filters.selectedFilters[filterId];
+ //console.log("enabling ",filterId,filterSelectionNote,newValue,oldValue);
+ scope.filters.selectedFilters[filterId] = newValue;
+ scope.filters.selectedIds[filterId] = newValue ? 'updated' : filterSelectionNote;
+ if (!newValue) delete scope.filters.selectedFilters[filterId];
+
+ if (newValue && filterSelectionNote==="theoretically-enabled") {
+ deferredCalls.push(()=> {
+ // trigger the handler, update other categories, if a category becomes available late
+ console.log("Delayed enablement of filter ", filterId);
+ // console.log("=");
+ newValue.select(filterId, newValue, true, false, true);
+ // console.log("--");
+ // console.log("CATS 1", Object.keys(scope.filters.selectedIds));
+ // console.log("CATS 2", Object.keys(scope.filters.selectedFilters));
+ // console.log("CATS 3", Object.keys(scope.filters.selectedIds));
+ });
+ }
+ });
+ deferredCalls.forEach(c => c());
+ }
+
+ // add counts
+ //updateSelectedFilters(filtersIncludingTags);
+
+ // filter and move to new map
+ let result = {};
+ Object.entries(filtersIncludingTags).forEach(([k, f]) => {
+ if (f.countAbsolute > 0) result[k] = f;
+ });
+
+ // and delete categories that are redundant
+ function deleteCategoryIfAllCountsAreEqual(category) {
+ if (_.uniq(Object.values(result).filter(f => f.category === category).map(f => f.countAbsolute)).length==1) {
+ Object.entries(result).filter(([k,f]) => f.category === category).forEach(([k,f])=>delete result[k]);
+ }
+ }
+ function deleteFiltersInCategoryThatAreEmpty(category) {
+ Object.entries(result).filter(([k,f]) => f.category === category && f.countAbsolute==0).forEach(([k,f])=>delete result[k]);
+ }
+ function deleteCategoryIfSize1(category) {
+ const found = Object.entries(result).filter(([k,f]) => f.category === category);
+ if (found.length==1) delete result[found[0][0]];
+ }
+ deleteFiltersInCategoryThatAreEmpty('nested');
+ deleteCategoryIfSize1('nested');
+ deleteCategoryIfAllCountsAreEqual('type/tag'); // because all tags are on all tasks
+
+ if (!result['_cross_entity'] && result['_recursive']) {
+ // if we don't have cross-entity sub-tasks, tidy this message
+ result['_recursive'].display = 'Include sub-tasks';
+ }
+
+ // // but if we deleted everything, restore them (better to have pointless categories than no categories)
+ // if (!Object.keys(result).length) result = filtersIncludingTags;
+
+
+ // now add dividers between categories
+ let lastCat = null;
+ for (let v of Object.values(result)) {
+ if (lastCat!=null && lastCat!=v.category) {
+ v.classes = (v.classes || '') + ' divider-above';
+ }
+ lastCat = v.category;
+ }
+
+ scope.filters.available = result;
+ updateSelectedFilters(result);
return result;
}
}
@@ -186,16 +552,27 @@ function isScheduled(task) {
function isTopLevelTask(t, tasksById) {
if (!t.submittedByTask) return true;
+ if (t.forceTopLevel) return true;
+ if (t.tags && t.tags.includes("TOP-LEVEL")) return true;
let submitter = tasksById[t.submittedByTask.metadata.id];
if (!submitter) return true;
if (isScheduled(submitter) && (!t.endTimeUtc || t.endTimeUtc<=0)) return true;
return false;
}
-
-function topLevelTasks(tasks) {
+function isNonTopLevelTask(t, tasksById) {
+ return !isTopLevelTask(t, tasksById);
+}
+function isCrossEntityTask(t, tasksById) {
+ if (isTopLevelTask(t, tasksById)) return false;
+ return t.submittedByTask.metadata.entityId !== t.entityId;
+}
+function isNestedSameEntityTask(t, tasksById) {
+ if (isTopLevelTask(t, tasksById)) return false;
+ return t.submittedByTask.metadata.entityId === t.entityId;
+}
+function filterWithId(tasks, tasksById, nextFilter) {
if (!tasks) return tasks;
- let tasksById = tasks.reduce( (result,t) => { result[t.id] = t; return result; }, {} );
- return tasks.filter(t => isTopLevelTask(t, tasksById));
+ return tasks.filter(t => nextFilter(t, tasksById));
}
export function timeAgoFilter() {
@@ -220,7 +597,6 @@ export function dateFilter() {
} else {
return moment(input).format('MMM D, yyyy @ HH:mm:ss.SSS');
}
- return "TODO - "+input;
}
return date;
@@ -240,8 +616,8 @@ function isTaskWithTag(task, tag) {
return task.tags.indexOf(tag)>=0;
}
-function tasksWithTag(tasks, tag) {
- return tasks.filter(t => isTaskWithTag(t, tag));
+function filterForTasksWithTag(tag) {
+ return (tasks) => tasks.filter(t => isTaskWithTag(t, tag));
}
function tasksAfterGlobalFilters(inputs, globalFilters) {
@@ -253,23 +629,7 @@ function tasksAfterGlobalFilters(inputs, globalFilters) {
return inputs;
}
-export function activityTagFilter() {
- return function (inputs, args) {
- const [tagF, globalFilters] = args;
- inputs = tasksAfterGlobalFilters(inputs, globalFilters);
- if (inputs && tagF) {
- const filter = tagF.filter || (tagF.tag ? inp => tasksWithTag(inp, tagF.tag) : null);
- if (!filter) {
- console.warn("Incomplete activities tag filter", tagF);
- return inputs;
- }
- return filter(inputs);
- } else {
- if (inputs) console.warn("Unknown activities tag filter", tagF);
- return inputs;
- }
- }
-}
+function cacheSelectedIdsFromFilters(scope) { scope.filters.selectedIds = { ...scope.filters.selectedFilters }; }
export function activityFilter($filter) {
return function (activities, searchText) {
diff --git a/ui-modules/app-inspector/app/components/task-list/task-list.less b/ui-modules/app-inspector/app/components/task-list/task-list.less
index 614cce11..4c92ad3c 100644
--- a/ui-modules/app-inspector/app/components/task-list/task-list.less
+++ b/ui-modules/app-inspector/app/components/task-list/task-list.less
@@ -45,6 +45,14 @@ task-list {
.activity-tag-filter {
flex: 0;
+
+ .selection-summary {
+ background-color: @gray-dark;
+ color: @gray-lighter;
+ padding: 3px 6px;
+ border-radius: 5px;
+ }
+
}
.activity-name-filter {
flex: 1;
@@ -53,40 +61,99 @@ task-list {
}
- .activity-tag-filter-tag {
- color: #A8B2B9;
- }
- .activity-tag-filter-action {
- color: mix(#A8B2B9, #444);
- }
- .activity-tag-filter-tag, .activity-tag-filter-action {
+ //.activity-tag-filter-tag {
+ // color: #A8B2B9;
+ //}
+ //.activity-tag-filter-action {
+ // color: mix(#A8B2B9, #444);
+ //}
+ .activity-tag-filter-error, .activity-tag-filter-tag, .activity-tag-filter-action {
-webkit-font-smoothing: antialiased;
padding: 5px 10px 5px 10px;
+ }
+ .activity-tag-filter-tag, .activity-tag-filter-action {
cursor: pointer;
transition: color 0.5s;
&:hover {
- color: #7B8C98;
+ //color: #7B8C98;
background-color: @dropdown-link-hover-bg;
+
.badge {
- background-color: #7B8C98;
+ //background-color: #7B8C98;
}
}
+
&.active {
- color: #3B558A;
+ //color: #3B558A;
background-color: @dropdown-link-active-bg;
+
.badge {
- background-color: #3B558A;
+// background-color: #3B558A;
}
+
&:hover {
- color: #3B558A;
+ //color: #3B558A;
}
}
+
+ .main {
+ margin-right: 1em;
+ }
.badge {
- color: #3b558a;
- color: white;
+ //color: #3b558a;
+ //color: white;
background-color: #e6e6e6;
- background-color: #A8B2B9;;
+ //background-color: #A8B2B9;
+
+ margin-left: 1ex;
+ float: right;
+
+ &.included {
+ background-color: @brand-primary;
+ }
+ &.more-excluded-elsewhere, &.more-excluded-elsewhere {
+ background-color: @gray-lighter;
+ }
+ &.excluded-here {
+ background-color: @primary-50;
+ }
+ }
+ }
+
+ .dropdown-menu.with-checks {
+ width: auto;
+
+ li {
+ padding-left: 2em;
+ &.divider-above {
+ border-top: 1px solid @gray-lighter;
+ margin-top: 6px;
+ padding-top: 10px;
+ }
+ }
+ .selected {
+ .check.if-selected {
+ margin-left: -1.5em;
+ display: block;
+ width: 0;
+ height: 0;
+ overflow: visible;
+ margin-top: 3px;
+ margin-bottom: -3px;
+ }
+ .included, .more-excluded-elsewhere {
+ display: block;
+ }
+ .excluded-here {
+ display: none;
+ }
+ }
+ .included, .more-excluded-elsewhere {
+ display: none;
+ }
+ .check.if-selected {
+ display: none;
}
}
}
diff --git a/ui-modules/app-inspector/app/components/task-list/task-list.template.html b/ui-modules/app-inspector/app/components/task-list/task-list.template.html
index 2db6f8ba..54ce89f1 100644
--- a/ui-modules/app-inspector/app/components/task-list/task-list.template.html
+++ b/ui-modules/app-inspector/app/components/task-list/task-list.template.html
@@ -19,16 +19,45 @@
<div class="no-activities" ng-if="tasks.length === 0">No activities</div>
<div ng-if="tasks.length !== 0" class="task-list">
<div class="form-group search-bar-with-controls">
- <div class="btn-group activity-tag-filter" uib-dropdown keyboard-nav="true" dropdown-append-to="model.appendTo">
+ <div class="btn-group activity-tag-filter" uib-dropdown keyboard-nav="true" dropdown-append-to="model.appendTo" ng-if="filters.selectedDisplayName">
<button id="single-button" type="button" class="btn btn-default" uib-dropdown-toggle>
- Displaying <kbd>{{model.filterByTag.display}}</kbd> <span class="caret"></span>
+ Show <span class="selection-summary">{{filters.selectedDisplayName}}</span> <span class="caret"></span>
</button>
- <ul class="dropdown-menu" uib-dropdown-menu role="menu" aria-labelledby="single-button">
- <li role="menuitem" class="activity-tag-filter-tag" ng-repeat="(tag,value) in filters track by tag" ng-model="model.filterByTag" uib-btn-radio="value">
- <span>{{value.display}}</span> <span class="badge">{{value.count}}</span>
+ <ul class="dropdown-menu with-checks" uib-dropdown-menu role="menu" aria-labelledby="single-button">
+ <li role="menuitem" ng-repeat="(tag,value) in filters.available track by tag"
+ class="activity-tag-filter-tag {{value.classes}}"
+ ng-click="value.onClick(tag, value)"
+ ng-class="{'selected': filters.selectedFilters[tag]}">
+
+ <i class="fa fa-check check if-selected"></i>
+
+ <span class="main" title="{{value.help}}">{{value.display}}</span>
+
+ <span class="badge included"
+ title="Activities included by this filter">
+ {{value.count}}
+ </span>
+ <span class="badge excluded-here"
+ ng-if="value.count > 0"
+ title="Activities included by this filter">
+ {{value.count}}
+ </span>
+ <span class="badge more-excluded-elsewhere"
+ title="Additional activities excluded by other filter categories"
+ ng-if="value.count > 0 && value.countAbsolute > value.count">
+ {{ value.countAbsolute - value.count}}
+ </span>
+ <span class="badge all-excluded-elsewhere"
+ title="Activities are excluded by other filter categories"
+ ng-if="value.count == 0 && value.countAbsolute > 0">
+ {{ value.countAbsolute - value.count}}
+ </span>
</li>
<li role="menuitem" class="activity-tag-filter-action" ng-if="globalFilters.transient" ng-click="globalFilters.transient.action()">
- <i><span>{{globalFilters.transient.display}}</span></i>
+ <i><span class="main">{{globalFilters.transient.display}}</span></i>
+ </li>
+ <li role="menuitem" class="activity-tag-filter-error" ng-if="!globalFilters.transient && isEmpty(filters.available)">
+ <i><span class="main">No filter options</span></i>
</li>
</ul>
</div>
@@ -55,7 +84,7 @@
</tr>
</thead>
<tbody>
- <tr ng-repeat="task in tasks | activityTagFilter : [model.filterByTag, globalFilters] | activityFilter:filterValue as filterResult track by task.id">
+ <tr ng-repeat="task in tasksFilteredByTag | activityFilter:filterValue as filterResult track by task.id">
<td class="status">
<a ui-sref="main.inspect.activities.detail({entityId: task.entityId, activityId: task.id, workflowId: getTaskWorkflowId(task)})">
<brooklyn-status-icon value="{{task.currentStatus}}" ng-if="!isScheduled(task)"></brooklyn-status-icon>
@@ -65,6 +94,9 @@
<td class="name">
<a ui-sref="main.inspect.activities.detail({entityId: task.entityId, activityId: task.id, workflowId: getTaskWorkflowId(task)})">{{task.displayName}}
</a>
+ {{ task.id }} -
+ prevRun={{ task.isWorkflowPreviousRun }}
+ replLatest={{ task.isReplayedWorkflowLatest }}
</td>
<td class="started">
<a ui-sref="main.inspect.activities.detail({entityId: task.entityId, activityId: task.id, workflowId: getTaskWorkflowId(task)})">
@@ -73,7 +105,7 @@
</td>
<td class="duration">
<a ui-sref="main.inspect.activities.detail({entityId: task.entityId, activityId: task.id, workflowId: getTaskWorkflowId(task)})"
- ng-if="task.startTimeUtc">
+ ng-if="task.startTimeUtc && task.startTimeUtc>0">
{{getTaskDuration(task) | durationFilter}} <span ng-if="task.endTimeUtc === null">and counting</span>
</a>
</td>
@@ -83,7 +115,7 @@
<td colspan="4" class="text-center"><h4>
No tasks found matching
<span ng-if="filterValue">current search <code>{{filterValue}}</code> and</span>
- filter <code>{{model.filterByTag.display}}</code>
+ filter <code>{{filters.selectedDisplayName}}</code>
</h4></td>
</tr>
</tbody>
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
index 30b8b8cf..7d6bb40c 100644
--- a/ui-modules/app-inspector/app/components/workflow/workflow-step.directive.js
+++ b/ui-modules/app-inspector/app/components/workflow/workflow-step.directive.js
@@ -64,6 +64,12 @@ export function workflowStepDirective() {
vm.nonEmpty = (data) => data && (data.length || Object.keys(data).length);
vm.isNullish = _.isNil;
+ vm.getWorkflowNameFromReference = (ref) => {
+ // would be nice to get a name, but all we have is appId, entityId, workflowId; and no lookup table;
+ // could look it up or store at server, but seems like overkill
+ return null;
+ };
+
$scope.json = null;
$scope.jsonMode = null;
vm.showJson = (mode, json) => {
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 5f0b78b4..57cb6d71 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
@@ -166,7 +166,7 @@
</div></div>
<div class="data-row nested with-buttons" ng-if="stepContext.subWorkflows && stepContext.subWorkflows.length"><div class="A" style="margin-top: 2px;">Sub-workflows</div> <div class="B">
- <div class="btn-group" uib-dropdown>
+ <div class="btn-group" uib-dropdown ng-if="stepContext.subWorkflows.length>1">
<button id="workflow-button" type="button" class="btn btn-select-dropdown workflow-button-small" uib-dropdown-toggle>
{{ stepContext.subWorkflows.length }} nested workflow{{ stepContext.subWorkflows.length>1 ? 's' : '' }} <span class="caret"></span>
</button>
@@ -174,9 +174,16 @@
<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})">
<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})">
+ <span>{{ vm.getWorkflowNameFromReference(stepContext.subWorkflows[0]) }}</span>
+ <span class="monospace">{{ stepContext.subWorkflows[0].workflowId }}</span>
+ </a>
+ </div>
</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>
@@ -194,7 +201,7 @@
<div class="btn-group right" uib-dropdown>
<button id="extra-data-button" type="button" class="btn btn-select-dropdown pull-right" uib-dropdown-toggle>
- View data <span class="caret"></span>
+ JSON <span class="caret"></span>
</button>
<ul class="dropdown-menu pull-right" uib-dropdown-menu role="menu" aria-labelledby="extra-data-button">
<li role="menuitem" > <a href="" ng-click="vm.showJson('stepContext', stepContext)" ng-class="{'selected' : jsonMode === 'stepContext'}">
diff --git a/ui-modules/app-inspector/app/views/main/inspect/activities/activities.controller.js b/ui-modules/app-inspector/app/views/main/inspect/activities/activities.controller.js
index 3e561ee8..96c15948 100644
--- a/ui-modules/app-inspector/app/views/main/inspect/activities/activities.controller.js
+++ b/ui-modules/app-inspector/app/views/main/inspect/activities/activities.controller.js
@@ -71,24 +71,37 @@ function ActivitiesController($scope, $state, $stateParams, $log, $timeout, enti
newActivitiesMap[activity.id] = activity;
});
- // TODO
- //(vm.workflows || [])
Object.values(vm.workflows || {})
+ .filter(wf => wf.replays && wf.replays.length)
.forEach(wf => {
- (wf.replays || []).forEach(wft => {
- let newActivity = newActivitiesMap[wft.taskId];
- if (!newActivity) {
- // create stub tasks for the replays of workflows
- newActivity = makeTaskStubFromWorkflowRecord(wf, wft);
- newActivitiesMap[wft.taskId] = newActivity;
- }
- newActivity.workflowId = wft.workflowId;
- newActivity.isWorkflowOldReplay = wft.workflowId !== wft.taskId;
+ const last = wf.replays[wf.replays.length-1];
+ let submitted = {};
+ let lastTask;
+
+ wf.replays.forEach(wft => {
+ let t = newActivitiesMap[wft.taskId];
+ if (!t) {
+ // create stub tasks for the replays of workflows
+ t = makeTaskStubFromWorkflowRecord(wf, wft);
+ newActivitiesMap[wft.taskId] = t;
+ }
+ t.workflowId = wft.workflowId;
+
+ // overriding submitters breaks things (infinite loop, in kilt?)
+ // so instead just set whether it is the latest replay
+ t.isWorkflowPreviousRun = last && wft.taskId !== last.taskId;
+ lastTask = t;
+ });
+ if (wf.replays.length>=2) lastTask.isReplayedWorkflowLatest = true;
});
- });
vm.activitiesMap = newActivitiesMap;
vm.activities = Object.values(vm.activitiesMap);
+ // TODO weird bug
+ // vm.activitiesUniq = _.uniq(Object.values(vm.activitiesMap));
+ // if (vm.activities.length != Object.values(vm.activitiesMap).length) {
+ // console.log("MISMATCH", vm.activitiesMap);
+ // }
}
}
@@ -107,7 +120,18 @@ function ActivitiesController($scope, $state, $stateParams, $log, $timeout, enti
$log.warn('Error loading activities for entity '+entityId, error);
vm.error = 'Cannot load activities for entity with ID: ' + entityId;
});
-
+
+ entityApi.getWorkflows(applicationId, entityId).then((response) => {
+ vm.workflows = response.data;
+ mergeActivities();
+ observers.push(response.subscribe((response) => {
+ vm.workflows = response.data;
+ mergeActivities();
+ }));
+ }).catch((error) => {
+ $log.warn('Error loading workflows for entity ' + entityId, error);
+ });
+
entityApi.entityActivitiesDeep(applicationId, entityId).then((response) => {
vm.activitiesDeep = response.data;
observers.push(response.subscribe((response) => {
@@ -115,22 +139,10 @@ function ActivitiesController($scope, $state, $stateParams, $log, $timeout, enti
vm.error = undefined;
}));
}).catch((error) => {
- $log.warn('Error loading activity children deep for entity '+entityId, error);
+ $log.warn('Error loading activity children deep for entity ' + entityId, error);
vm.error = 'Cannot load activities (deep) for entity with ID: ' + entityId;
});
- entityApi.getWorkflows(applicationId, entityId).then((response) => {
- vm.workflows = response.data;
- mergeActivities();
- observers.push(response.subscribe((response) => {
- vm.workflows = response.data;
- mergeActivities();
- }));
- }).catch((error) => {
- $log.warn('Error loading workflows for entity '+entityId, error);
- });
-
-
$scope.$on('$destroy', () => {
observers.forEach((observer) => {
observer.unsubscribe();
@@ -146,7 +158,7 @@ function ActivitiesController($scope, $state, $stateParams, $log, $timeout, enti
}
export function makeTaskStubFromWorkflowRecord(wf, wft) {
- return {
+ const result = {
id: wft.taskId,
displayName: wf.name + (wft.reasonForReplay ? " ("+wft.reasonForReplay+")" : ""),
entityId: (wf.entity || {}).id,
@@ -155,6 +167,7 @@ export function makeTaskStubFromWorkflowRecord(wf, wft) {
submitTimeUtc: wft.submitTimeUtc,
startTimeUtc: wft.startTimeUtc,
endTimeUtc: wft.endTimeUtc,
+ isTaskStubFromWorkflowRecord: true,
tags: [
"WORKFLOW",
{
@@ -164,4 +177,5 @@ export function makeTaskStubFromWorkflowRecord(wf, wft) {
},
],
};
+ return result;
};
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 a106bc04..de1fa975 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
@@ -155,8 +155,7 @@ 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
-
- loadWorkflow(null).then(()=> {
+ 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);
@@ -167,13 +166,18 @@ function DetailController($scope, $state, $stateParams, $log, $uibModal, $timeou
// 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 || 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.') );
+ }
- }).catch(error2 => {
- $log.debug("ID "+activityId+" does not correspond to workflow either", error2);
- });
+ loadWorkflow({workflowId: activityId}).then(onNonTaskWorkflowLoad)
+ .catch(error => {
+ loadWorkflow(null).then(onNonTaskWorkflowLoad)
+ .catch(error => {
+ $log.debug("ID "+activityId+"/"+$scope.workflowId+" does not correspond to workflow either", error);
+ });
+ });
});
activityApi.activityChildren(activityId).then((response)=> {
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 38eb4b34..0c01da1d 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
@@ -204,7 +204,7 @@
}
.selected {
- .check {
+ .check.if-selected {
margin-left: -1.5em;
display: block;
width: 0;
@@ -214,8 +214,7 @@
margin-bottom: -3px;
}
}
-
- .check {
+ .check.if-selected {
display: none;
}
}
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 c96f9b75..dd8d6115 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
@@ -101,14 +101,14 @@
</div>
<div class="summary-block" ng-mouseenter="showUTC=true" ng-mouseleave="showUTC=false">
<div class="row">
- <div ng-if="vm.model.activity.endTimeUtc" class="col-md-3 summary-item summary-item-timestamp">
+ <div ng-if="vm.model.activity.endTimeUtc && vm.model.activity.endTimeUtc>0" class="col-md-3 summary-item summary-item-timestamp">
<div class="summary-item-icon">
<div class="icon-stopwatch"></div>
</div>
<div class="summary-item-label">Duration</div>
<div class="summary-item-value">
<div class="humanized fade" ng-show="!showUTC">
- took {{vm.model.activity.endTimeUtc- vm.model.activity.startTimeUtc | durationFilter}}
+ took {{vm.model.activity.endTimeUtc - vm.model.activity.startTimeUtc | durationFilter}}
</div>
<div class="utcTime fade" ng-show="showUTC">
{{vm.model.activity.endTimeUtc- vm.model.activity.startTimeUtc }} ms
@@ -194,7 +194,7 @@
<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 }}">
<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"></i>
+ <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
@@ -202,7 +202,10 @@
</a> </li>
<li role="menuitem">
- <a href="" ng-click="vm.showReplayHelp()" ng-class="{'selected' : showReplayHelp}"><i>More information</i></a>
+ <a href="" ng-click="vm.showReplayHelp()" ng-class="{'selected' : showReplayHelp}">
+ <i class="fa fa-check check if-selected"></i>
+ <i>More information</i>
+ </a>
</li>
</ul>
</div>