From 458f87bdf954163359244a3381a522b8cd997d57 Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Wed, 22 Jun 2016 14:55:59 -0400 Subject: [PATCH] brace Activity Stream against breaking errors, add RBAC role association/disassociation descriptions, close details modal on state exit, resolves #2582, #1721 --- .../activity-stream/activitystream.route.js | 5 + .../streamDetailModal.partial.html | 2 +- awx/ui/client/src/widgets/Stream.js | 433 +++++++----------- 3 files changed, 182 insertions(+), 258 deletions(-) diff --git a/awx/ui/client/src/activity-stream/activitystream.route.js b/awx/ui/client/src/activity-stream/activitystream.route.js index 06977c591d..4da5343651 100644 --- a/awx/ui/client/src/activity-stream/activitystream.route.js +++ b/awx/ui/client/src/activity-stream/activitystream.route.js @@ -17,6 +17,11 @@ export default { ncyBreadcrumb: { label: "ACTIVITY STREAM" }, + onExit: function(){ + $('#stream-detail-modal').modal('hide'); + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + }, resolve: { features: ['FeaturesService', 'ProcessErrors', '$state', '$rootScope', function(FeaturesService, ProcessErrors, $state, $rootScope) { diff --git a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html index 4806a2eb4d..f5d5acf553 100644 --- a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html +++ b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html @@ -14,7 +14,7 @@
ACTION
-
+
CHANGES
diff --git a/awx/ui/client/src/widgets/Stream.js b/awx/ui/client/src/widgets/Stream.js index 3e3eed8842..60f3bd6cc0 100644 --- a/awx/ui/client/src/widgets/Stream.js +++ b/awx/ui/client/src/widgets/Stream.js @@ -22,206 +22,189 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti 'RefreshHelper', listGenerator.name, 'StreamWidget', ]) -.factory('setStreamHeight', [ - function () { - return function () { - // Try not to overlap footer. Because stream is positioned absolute, the parent - // doesn't resize correctly when stream is loaded. - var sheight = $('#stream-content').height(), - theight = parseInt($('#main-view').css('min-height').replace(/px/, '')), - height = (theight < sheight) ? sheight : theight; - $('#main-view').css({ - "min-height": height - }); - }; - } -]) - -.factory('FixUrl', [ - function () { - return function (u) { - return u.replace(/\/api\/v1\//, '/#/'); - }; - } -]) - -.factory('BuildUrl', [ - function () { - return function (obj, inventory) { +.factory('BuildAnchor', [ '$log', + // Returns a full resource_name HTML string if link can be derived from supplied context + // returns name of resource if activity stream object doesn't contain enough data to build a UI url + // arguments are: a summary_field object, a resource type, an activity stream object + function ($log) { + return function (obj, resource, activity) { var url = '/#/'; - switch (obj.base) { - case 'group': - url += 'inventories/' + inventory.id + '/manage?group=' + obj.id; - break; - case 'host': - url += 'home/' + obj.base + 's/?id=' + obj.id; - break; - case 'job': - url += 'jobs/?id__int=' + obj.id; - break; - case 'inventory': - url += 'inventories/' + obj.id + '/'; - break; - case 'schedule': - url = (obj.url) ? '/#' + obj.url : ''; - break; - default: - url += obj.base + 's/' + obj.id + '/'; + // try/except pattern asserts that: + // if we encounter a case where a UI url can't or shouldn't be generated, just supply the name of the resource + try { + switch (resource) { + case 'group': + if (activity.operation === 'create' || activity.operation === 'delete'){ + // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey' + var inventory_id = _.last(activity.changes.inventory.split('-')); + url += 'inventories/' + inventory_id + '/manage?group=' + activity.changes.id; + } + else { + url += 'inventories/' + activity.summary_fields.inventory[0].id + '/manage?group=' + (activity.changes.id || activity.changes.object1_pk); + } + break; + case 'host': + url += 'home/hosts/' + obj.id; + break; + case 'job': + url += 'jobs/?id=' + obj.id; + break; + case 'inventory': + url += 'inventories/' + obj.id + '/'; + break; + case 'schedule': + // schedule urls depend on the resource they're associated with + if (activity.summary_fields.job_template){ + url += 'job_templates/' + activity.summary_fields.job_template.id + '/schedules/' + obj.id; + } + else if (activity.summary_fields.project){ + url += 'projects/' + activity.summary_fields.project.id + '/schedules/' + obj.id; + } + else if (activity.summary_fields.system_job_template){ + url += 'management_jobs/' + activity.summary_fields.system_job_template.id + '/schedules/edit/' + obj.id; + } + // urls for inventory sync schedules currently depend on having an inventory id and group id + else { + throw {name : 'NotImplementedError', message : 'activity.summary_fields to build this url not implemented yet'}; + } + break; + case 'notification_template': + throw {name : 'NotImplementedError', message : 'activity.summary_fields to build this url not implemented yet'}; + case 'role': + throw {name : 'NotImplementedError', message : 'role object management is not consolidated to a single UI view'}; + default: + url += resource + 's/' + obj.id + '/'; + } + return ' ' + (obj.name || obj.username) + ' '; + } + catch(err){ + $log.debug(err); + return ' ' + (obj.name || obj.username || '') + ' '; } - return url; }; } ]) -.factory('BuildDescription', ['$filter', 'FixUrl', 'BuildUrl','$sce', - function ($filter, FixUrl, BuildUrl, $sce) { +.factory('BuildDescription', ['BuildAnchor', '$log', + function (BuildAnchor, $log) { return function (activity) { - function stripDeleted(s) { - return s.replace(/^_deleted_\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+\+\d+:\d+_/, ''); - } + var pastTense = function(operation){ + return (/e$/.test(activity.operation)) ? operation + 'd ' : operation + 'ed '; + }; + // convenience method to see if dis+association operation involves 2 groups + // the group cases are slightly different because groups can be dis+associated into each other + var isGroupRelationship = function(activity){ + return activity.object1 === 'group' && activity.object2 === 'group' && activity.summary_fields.group.length > 1; + }; - var descr, descr_nolink, obj1, obj2, obj1_obj, obj2_obj, name, name_nolink; - - descr = activity.operation; - descr += (/e$/.test(activity.operation)) ? 'd ' : 'ed '; - descr_nolink = descr; - - // labels - obj1 = activity.object1; - obj2 = activity.object2; - - // objects - obj1_obj = (activity.summary_fields[obj1]) ? activity.summary_fields[obj1][0] : null; - if (obj1 === obj2) { - obj2_obj = activity.summary_fields[obj1][1]; - } else if (activity.summary_fields[obj2]) { - obj2_obj = activity.summary_fields[obj2][0]; - } else { - obj2_obj = null; - } - - if (obj1 === 'user' || obj2 === 'user') { - activity.summary_fields.user[0].name = activity.summary_fields.user[0].username; - } - // The block until line 221 is for associative/disassociative operations, such as adding/removing a user to a team or vise versa - if (obj2_obj && obj2_obj.name && !/^_delete/.test(obj2_obj.name)) { - obj2_obj.base = obj2; - obj2_obj.name = $filter('sanitize')(obj2_obj.name); - descr += obj2 + - " " + - obj2_obj.name + ''; - if (activity.object_association === 'admins') { - if (activity.operation === 'disassociate') { - descr += ' from being an admin of '; - } else { - descr += ' as an admin of '; - } - } else { - if (activity.operation === 'disassociate') { - descr += ' from '; - } else { - descr += ' to '; - } - } - descr_nolink += obj2 + ' ' + obj2_obj.name; - if (activity.object_association === 'admins') { - if (activity.operation === 'disassociate') { - descr_nolink += ' from being an admin of '; - } else { - descr_nolink += ' as an admin of '; - } - } else { - if (activity.operation === 'disassociate') { - descr_nolink += ' from '; - } else { - descr_nolink += ' to '; - } - } - } else if (obj2) { - name = ''; - if (obj2_obj && obj2_obj.name) { - name = ' ' + stripDeleted(obj2_obj.name); - } - if (activity.object_association === 'admins') { - if (activity.operation === 'disassociate') { - descr += ' from being an admin of '; - } else { - descr += ' as an admin of '; - } - } else { - if (activity.operation === 'disassociate') { - descr += ' from '; - } else { - descr += ' to '; - } - } - descr_nolink += (obj2_obj && obj2_obj.name) ? obj2 + ' ' + obj2_obj.name : obj2 + ' '; - if (activity.object_association === 'admins') { - if (activity.operation === 'disassociate') { - descr_nolink += ' from being an admin of '; - } else { - descr_nolink += ' as an admin of '; - } - } else { - if (activity.operation === 'disassociate') { - descr_nolink += ' from '; - } else { - descr_nolink += ' to '; - } - } - } - if (obj1_obj && obj1_obj.name && !/^\_delete/.test(obj1_obj.name)) { - obj1_obj.base = obj1; - // Need to character escape the link names, as a malicious url or piece of html could be inserted here that could take the - // user to a unknown location. - obj1_obj.name = $filter('sanitize')(obj1_obj.name); - obj1_obj.name = $sce.getTrustedHtml(obj1_obj.name); - descr += obj1 + " " + obj1_obj.name + ''; - descr_nolink += obj1 + ' ' + obj1_obj.name; - } else if (obj1) { - name = ''; - name_nolink = ''; - // find the name in changes, if needed - if (!(obj1_obj && obj1_obj.name) || obj1_obj && obj1_obj.name && /^_delete/.test(obj1_obj.name)) { - if (activity.changes && activity.changes.name) { - if (typeof activity.changes.name === 'string') { - name = ' ' + activity.changes.name; - name_nolink = name; - } else if (typeof activity.changes.name === 'object' && Array.isArray(activity.changes.name)) { - name = ' ' + activity.changes.name[0]; - name_nolink = name; + // Activity stream objects will outlive the resources they reference + // in that case, summary_fields will not be available - show generic error text instead + try { + activity.description = pastTense(activity.operation); + switch(activity.object_association){ + // explicit role dis+associations + case 'role': + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch(activity.operation){ + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)){ + activity.description += BuildAnchor(activity.summary_fields.group[1], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' from ' + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity); + } + else{ + activity.description += BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' from ' + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)){ + activity.description += BuildAnchor(activity.summary_fields.group[1], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' to ' + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity); + } + else{ + activity.description += BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' to ' + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } + break; } - /*} else if (obj1 === 'job' && obj1_obj && activity.changes && activity.changes.job_template) { - // Hack for job activity where the template name is known - if (activity.operation !== 'delete') { - obj1_obj.base = obj1; - name = ' ' + '' + obj1_obj.id + ' ' + activity.changes.job_template + ''; - name_nolink = ' ' + obj1_obj.id + ' ' + activity.changes.job_template; - } else { - name = ' ' + obj1_obj.id + ' ' + activity.changes.job_template; - name_nolink = name; + break; + // inherited role dis+associations (logic identical to case 'role') + case 'parents': + // object1 field is resource targeted by the dis+association + // object2 field is the resource the role is inherited from + // summary_field.role[0] contains ref info about the role + switch(activity.operation){ + // expected outcome: "disassociated role_name from " + case 'disassociate': + if (isGroupRelationship(activity)){ + activity.description += activity.object2 + BuildAnchor(activity.summary_fields.group[1], activity.object2, activity) + + 'from ' + activity.object1 + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity); + } + else{ + activity.description += BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' from ' + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } + break; + // expected outcome: "associated role_name to " + case 'associate': + if (isGroupRelationship(activity)){ + activity.description += activity.object1 + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity) + + 'to ' + activity.object2 + BuildAnchor(activity.summary_fields.group[1], activity.object2, activity); + } + else{ + activity.description += BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity) + activity.summary_fields.role[0].role_field + + ' to ' + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } + break; } - } else if (obj1 === 'job' && obj1_obj) { - // Hack for job activity where template name not known - if (activity.operation !== 'delete') { - obj1_obj.base = obj1; - name = ' ' + '' + obj1_obj.id + ''; - name_nolink = ' ' + obj1_obj.id; - } else { - name = ' ' + obj1_obj.id; - name_nolink = name; - }*/ - } - } else if (obj1_obj && obj1_obj.name) { - name = ' ' + stripDeleted(obj1_obj.name); - name_nolink = name; + break; + // CRUD operations / resource on resource dis+associations + default: + switch(activity.operation){ + // expected outcome: "disassociated from " + case 'disassociate' : + if (isGroupRelationship(activity)){ + activity.description += activity.object2 + BuildAnchor(activity.summary_fields.group[1], activity.object2, activity) + + 'from ' + activity.object1 + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity); + } + else { + activity.description += activity.object2 + BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity) + + 'from ' + activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + } + break; + // expected outcome "associated to " + case 'associate': + // groups are the only resource that can be associated/disassociated into each other + if (isGroupRelationship(activity)){ + activity.description += activity.object1 + BuildAnchor(activity.summary_fields.group[0], activity.object1, activity) + + 'to ' + activity.object2 + BuildAnchor(activity.summary_fields.group[1], activity.object2, activity); + } + else { + activity.description += activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity) + + 'to ' + activity.object2 + BuildAnchor(activity.summary_fields[activity.object2][0], activity.object2, activity); + } + break; + case 'delete': + activity.description += activity.object1 + BuildAnchor(activity.changes, activity.object1, activity); + break; + // equivalent to 'create' or 'update' + // expected outcome: "operation " + default: + activity.description += activity.object1 + BuildAnchor(activity.summary_fields[activity.object1][0], activity.object1, activity); + break; + } + break; } - descr += obj1 + name; - descr_nolink += obj1 + name_nolink; } - activity.description = descr; - activity.description_nolink = descr_nolink; + catch(err){ + $log.debug(err); + activity.description = 'Event summary not available'; + } }; } ]) @@ -247,7 +230,7 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti scope.changes = activity.changes; scope.user = ((activity.summary_fields.actor) ? activity.summary_fields.actor.username : 'system') + ' on ' + $filter('longDate')(activity.timestamp); - scope.operation = activity.description_nolink; + scope.operation = activity.description; scope.header = "Event " + activity.id; // Open the modal @@ -267,11 +250,11 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti .factory('Stream', ['$rootScope', '$location', '$state', 'Rest', 'GetBasePath', 'ProcessErrors', 'Wait', 'StreamList', 'SearchInit', 'PaginateInit', - 'generateList', 'FormatDate', 'BuildDescription', 'FixUrl', 'BuildUrl', - 'ShowDetail', 'setStreamHeight', + 'generateList', 'FormatDate', 'BuildDescription', + 'ShowDetail', function ($rootScope, $location, $state, Rest, GetBasePath, ProcessErrors, Wait, StreamList, SearchInit, PaginateInit, GenerateList, FormatDate, - BuildDescription, FixUrl, BuildUrl, ShowDetail, setStreamHeight) { + BuildDescription, ShowDetail) { return function (params) { var list = _.cloneDeep(StreamList), @@ -418,82 +401,18 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti scope.removeStreamPostRefresh(); } scope.removeStreamPostRefresh = scope.$on('PostRefresh', function () { - var href, deleted, obj1, obj2; scope.activities.forEach(function(activity, i) { - var row = scope.activities[i], - type, url; - + // build activity.user if (scope.activities[i].summary_fields.actor) { scope.activities[i].user = "" + scope.activities[i].summary_fields.actor.username + ""; } else { scope.activities[i].user = 'system'; } - - // Objects - deleted = /^\_delete/; - obj1 = scope.activities[i].object1; - obj2 = scope.activities[i].object2; - - if ((obj1 === "schedule" || obj2 === "schedule") && activity.summary_fields.schedule) { - if (activity.summary_fields.inventory_source) { - type = 'inventory_source'; - url = '/home/groups/?inventory_source__id=' + row.summary_fields.inventory_source.id; - } - else if (activity.summary_fields.project) { - type = 'project'; - url = '/projects/' + activity.summary_fields[type].id + '/schedules/?id__int='; - } - else if (activity.summary_fields.system_job_template) { - type = 'system_job_template'; - url = '/system_job_templates/' + activity.summary_fields[type].id + '/schedules/?id__int='; - } - else if (activity.summary_fields.job_template) { - type = 'job_template'; - url = '/job_templates/' + activity.summary_fields[type].id + '/schedules/?id__int='; - } - if (obj1 === 'schedule') { - row.summary_fields.schedule[0].url = url + ((type === 'inventory_source') ? '' : row.summary_fields.schedule[0].id); - row.summary_fields.schedule[0].type = type; - row.summary_fields.schedule[0].type_id = activity.summary_fields[type].id; - row.summary_fields.schedule[0].base = 'schedule'; - } - if (obj2 === 'schedule') { - row.summary_fields.schedule[1].url = url + ((type === 'inventory_source') ? '' : row.summary_fields.schedule[1].id); - row.summary_fields.schedule[1].type = type; - row.summary_fields.schedule[1].type_id = activity.summary_fields[type].id; - row.summary_fields.schedule[1].base = 'schedule'; - } - } - - if (obj1 && scope.activities[i].summary_fields[obj1] && scope.activities[i].summary_fields[obj1].name) { - if (!deleted.test(scope.activities[i].summary_fields[obj1].name)) { - href = BuildUrl(scope.activities[i].summary_fields[obj1]); - scope.activities[i].objects = "" + scope.activities[i].summary_fields[obj1].name + ""; - } else { - scope.activities[i].objects = scope.activities[i].summary_fields[obj1].name; - } - } else if (scope.activities[i].object1) { - scope.activities[i].objects = scope.activities[i].object1; - } - if (obj2 && scope.activities[i].summary_fields[obj2] && scope.activities[i].summary_fields[obj2].name) { - if (!deleted.test(scope.activities[i].summary_fields[obj2].name)) { - href = BuildUrl(scope.activities[i].summary_fields[obj2]); - scope.activities[i].objects += ", " + scope.activities[i].summary_fields[obj2].name + ""; - } else { - scope.activities[i].objects += "," + scope.activities[i].summary_fields[obj2].name; - } - } else if (scope.activities[i].object2) { - scope.activities[i].objects += ", " + scope.activities[i].object2; - } - + // build description column / action text BuildDescription(scope.activities[i]); }); - // Give ng-repeate a chance to show the data before adjusting the page size. - setTimeout(function () { - setStreamHeight(); - }, 500); }); // Initialize search and paginate pieces and load data