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 <[email protected]> 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>
