diff --git a/awx/ui/static/css/custom-theme/jquery-ui-1.10.3.custom.css b/awx/ui/static/css/custom-theme/jquery-ui-1.10.3.custom.css index a268ebf601..dbec9aa337 100644 --- a/awx/ui/static/css/custom-theme/jquery-ui-1.10.3.custom.css +++ b/awx/ui/static/css/custom-theme/jquery-ui-1.10.3.custom.css @@ -846,7 +846,7 @@ body .ui-tooltip { .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #e3e3e3; - background: #e5e3e3 url(/static/css/images/ui-bg_flat_75_e5e3e3_40x100.png) 50% 50% repeat-x; + background: #e5e3e3 url(/static/css/custom-theme/images/ui-bg_flat_75_e5e3e3_40x100.png) 50% 50% repeat-x; font-weight: bold; color: #005580; } diff --git a/awx/ui/static/js/app.js b/awx/ui/static/js/app.js index 7752795972..6b48bd8be0 100644 --- a/awx/ui/static/js/app.js +++ b/awx/ui/static/js/app.js @@ -84,7 +84,8 @@ angular.module('ansible', [ 'TimerService', 'StreamListDefinition', 'HomeGroupListDefinition', - 'HomeHostListDefinition' + 'HomeHostListDefinition', + 'ActivityDetailDefinition' ]) .config(['$routeProvider', function($routeProvider) { $routeProvider. diff --git a/awx/ui/static/js/controllers/Home.js b/awx/ui/static/js/controllers/Home.js index 735604c501..ba7a6bc53e 100644 --- a/awx/ui/static/js/controllers/Home.js +++ b/awx/ui/static/js/controllers/Home.js @@ -16,28 +16,33 @@ function Home ($routeParams, $scope, $rootScope, $location, Wait, ObjectCount, J ClearScope('home'); //Garbage collection. Don't leave behind any listeners/watchers from the prior //scope. - var waitCount = 4; - var loadedCount = 0; - - if (!$routeParams['login']) { - // If we're not logging in, start the Wait widget. Otherwise, it's already running. - Wait('start'); - } - - JobStatus({ target: 'container1' }); - InventorySyncStatus({ target: 'container2' }); - SCMSyncStatus({ target: 'container4' }); - ObjectCount({ target: 'container3' }); - - $rootScope.showActivity = function() { Stream(); } - - $rootScope.$on('WidgetLoaded', function() { - // Once all the widgets report back 'loaded', turn off Wait widget - loadedCount++; - if ( loadedCount == waitCount ) { - Wait('stop'); + var load = function() { + var waitCount = 4; + var loadedCount = 0; + + if (!$routeParams['login']) { + // If we're not logging in, start the Wait widget. Otherwise, it's already running. + Wait('start'); } - }); + + JobStatus({ target: 'container1' }); + InventorySyncStatus({ target: 'container2' }); + SCMSyncStatus({ target: 'container4' }); + ObjectCount({ target: 'container3' }); + + $rootScope.$on('WidgetLoaded', function() { + // Once all the widgets report back 'loaded', turn off Wait widget + loadedCount++; + if ( loadedCount == waitCount ) { + Wait('stop'); + } + }); + } + + $rootScope.showActivity = function() { Stream(); } + $rootScope.refresh = function() { load(); } + + load(); } Home.$inject=[ '$routeParams', '$scope', '$rootScope', '$location', 'Wait', 'ObjectCount', 'JobStatus', 'InventorySyncStatus', @@ -96,6 +101,14 @@ function HomeGroups ($location, $routeParams, HomeGroupList, GenerateList, Proce PaginateInit({ scope: scope, list: list, url: defaultUrl }); // Process search params + if ($routeParams['name']) { + scope[list.iterator + 'InputDisable'] = false; + scope[list.iterator + 'SearchValue'] = $routeParams['name']; + scope[list.iterator + 'SearchField'] = 'name'; + scope[list.iterator + 'SearchFieldLabel'] = list.fields['name'].label; + scope[list.iterator + 'SearchSelectValue'] = null; + } + if ($routeParams['has_active_failures']) { scope[list.iterator + 'InputDisable'] = true; scope[list.iterator + 'SearchValue'] = $routeParams['has_active_failures']; @@ -180,7 +193,16 @@ function HomeHosts ($location, $routeParams, HomeHostList, GenerateList, Process SearchInit({ scope: scope, set: 'hosts', list: list, url: defaultUrl }); PaginateInit({ scope: scope, list: list, url: defaultUrl }); - + + // Process search params + if ($routeParams['name']) { + scope[list.iterator + 'InputDisable'] = false; + scope[list.iterator + 'SearchValue'] = $routeParams['name']; + scope[list.iterator + 'SearchField'] = 'name'; + scope[list.iterator + 'SearchFieldLabel'] = list.fields['name'].label; + scope[list.iterator + 'SearchSelectValue'] = null; + } + if ($routeParams['has_active_failures']) { scope[HomeHostList.iterator + 'InputDisable'] = true; scope[HomeHostList.iterator + 'SearchValue'] = $routeParams['has_active_failures']; diff --git a/awx/ui/static/js/controllers/Organizations.js b/awx/ui/static/js/controllers/Organizations.js index f325f8161f..a64cfc4b61 100644 --- a/awx/ui/static/js/controllers/Organizations.js +++ b/awx/ui/static/js/controllers/Organizations.js @@ -12,7 +12,7 @@ function OrganizationsList ($routeParams, $scope, $rootScope, $location, $log, Rest, Alert, LoadBreadCrumbs, Prompt, GenerateList, OrganizationList, SearchInit, PaginateInit, ClearScope, ProcessErrors, - GetBasePath, SelectionInit, Wait) + GetBasePath, SelectionInit, Wait, Stream) { ClearScope('htmlTemplate'); //Garbage collection. Don't leave behind any listeners/watchers from the prior //scope. @@ -36,6 +36,8 @@ function OrganizationsList ($routeParams, $scope, $rootScope, $location, $log, R PaginateInit({ scope: scope, list: list, url: defaultUrl }); scope.search(list.iterator); + scope.showActivity = function() { Stream(); } + scope.addOrganization = function() { $location.path($location.path() + '/add'); } @@ -70,7 +72,7 @@ function OrganizationsList ($routeParams, $scope, $rootScope, $location, $log, R OrganizationsList.$inject=[ '$routeParams', '$scope', '$rootScope', '$location', '$log', 'Rest', 'Alert', 'LoadBreadCrumbs', 'Prompt', 'GenerateList', 'OrganizationList', 'SearchInit', 'PaginateInit', 'ClearScope', 'ProcessErrors', - 'GetBasePath', 'SelectionInit', 'Wait' ]; + 'GetBasePath', 'SelectionInit', 'Wait', 'Stream']; function OrganizationsAdd ($scope, $rootScope, $compile, $location, $log, $routeParams, OrganizationForm, diff --git a/awx/ui/static/js/forms/ActivityDetail.js b/awx/ui/static/js/forms/ActivityDetail.js new file mode 100644 index 0000000000..e6a6ba16d5 --- /dev/null +++ b/awx/ui/static/js/forms/ActivityDetail.js @@ -0,0 +1,59 @@ +/********************************************* + * Copyright (c) 2013 AnsibleWorks, Inc. + * + * ActivityDetail.js + * Form definition for Activity Stream detail + * + */ +angular.module('ActivityDetailDefinition', []) + .value( + 'ActivityDetailForm', { + + name: 'activity', + editTitle: 'Activity Detail', + well: false, + 'class': 'horizontal-narrow', + + fields: { + timestamp: { + label: 'Time', + type: 'text', + readonly: true + }, + operation: { + label: 'Operation', + type: 'text', + readonly: true + }, + object1: { + label: 'Object 1', + type: 'text', + ngHide: '!object1', + readonly: true + }, + object1_name: { + label: 'Name', + type: 'text', + ngHide: '!object1', + readonly: true + }, + object2: { + label: 'Object 2', + type: 'text', + ngHide: '!object2', + readonly: true + }, + object2_name: { + label: 'Name', + type: 'text', + ngHide: '!object2', + readonly: true + }, + changes: { + label: 'Changes', + type: 'textarea', + readonly: true + } + } + + }); //Form diff --git a/awx/ui/static/js/helpers/search.js b/awx/ui/static/js/helpers/search.js index d9ac0443cf..ecb6775ee0 100644 --- a/awx/ui/static/js/helpers/search.js +++ b/awx/ui/static/js/helpers/search.js @@ -125,7 +125,12 @@ angular.module('SearchHelper', ['RestServices', 'Utilities', 'RefreshHelper']) } else if (list.fields[fld].searchType && list.fields[fld].searchType == 'int') { scope[iterator + 'HideSearchType'] = true; - } + } + else if (list.fields[fld].searchType && list.fields[fld].searchType == 'isnull') { + scope[iterator + 'SearchType'] = 'isnull'; + scope[iterator + 'InputDisable'] = true; + scope[iterator + 'SearchValue'] = 'true'; + } scope.search(iterator); } @@ -187,6 +192,16 @@ angular.module('SearchHelper', ['RestServices', 'Utilities', 'RefreshHelper']) scope[iterator + 'SearchSelectValue'].value == null) ) { scope[iterator + 'SearchParams'] += 'iexact='; } + else if ( (list.fields[scope[iterator + 'SearchField']].searchType && + (list.fields[scope[iterator + 'SearchField']].searchType == 'or')) ) { + scope[iterator + 'SearchParams'] = ''; //start over + for (var k=0; k < list.fields[scope[iterator + 'SearchField']].searchFields.length; k++) { + scope[iterator + 'SearchParams'] += '&or__' + + list.fields[scope[iterator + 'SearchField']].searchFields[k] + + '__icontains=' + escape(scope[iterator + 'SearchValue']); + } + scope[iterator + 'SearchParams'].replace(/^\&/,''); + } else { scope[iterator + 'SearchParams'] += scope[iterator + 'SearchType'] + '='; } @@ -197,9 +212,10 @@ angular.module('SearchHelper', ['RestServices', 'Utilities', 'RefreshHelper']) scope[iterator + 'SearchParams'] += scope[iterator + 'SearchSelectValue'].value; } else { - //if ( list.fields[scope[iterator + 'SearchField']].searchType == undefined || - // list.fields[scope[iterator + 'SearchField']].searchType == 'gtzero' ) { - scope[iterator + 'SearchParams'] += escape(scope[iterator + 'SearchValue']); + if ( (list.fields[scope[iterator + 'SearchField']].searchType && + (list.fields[scope[iterator + 'SearchField']].searchType !== 'or')) ) { + scope[iterator + 'SearchParams'] += escape(scope[iterator + 'SearchValue']); + } } scope[iterator + 'SearchParams'] += (sort_order) ? '&order_by=' + escape(sort_order) : ''; } diff --git a/awx/ui/static/js/lists/Organizations.js b/awx/ui/static/js/lists/Organizations.js index 964a5708c7..39c6501faa 100644 --- a/awx/ui/static/js/lists/Organizations.js +++ b/awx/ui/static/js/lists/Organizations.js @@ -35,6 +35,15 @@ angular.module('OrganizationListDefinition', []) ngClick: 'addOrganization()', "class": 'btn-success btn-xs', awToolTip: 'Create a new organization' + }, + stream: { + 'class': "btn-primary btn-xs activity-btn", + ngClick: "showActivity()", + awToolTip: "View Activity Stream", + dataPlacement: "top", + icon: "icon-comments-alt", + mode: 'all', + iconSize: 'large' } }, diff --git a/awx/ui/static/js/lists/Streams.js b/awx/ui/static/js/lists/Streams.js index 76c023ede5..5fd025cd08 100644 --- a/awx/ui/static/js/lists/Streams.js +++ b/awx/ui/static/js/lists/Streams.js @@ -19,23 +19,45 @@ angular.module('StreamListDefinition', []) "class": "table-condensed", fields: { + timestamp: { + label: 'Event Time', + key: true, + desc: true, + noLink: true, + searchable: false + }, user: { label: 'User', - linkTo: "\{\{ activity.userLink \}\}", + ngBindHtml: 'activity.user', sourceModel: 'user', sourceField: 'username', awToolTip: "\{\{ userToolTip \}\}", dataPlacement: 'top' }, - timestamp: { - label: 'Event Time', - }, objects: { label: 'Objects', - ngBindHtml: 'activity.objects' + ngBindHtml: 'activity.objects', + sortField: "object1__name,object2__name", + searchable: false + }, + object_name: { + label: 'Object name', + searchOnly: true, + searchType: 'or', + searchFields: ['object1__name', 'object2__name'] }, description: { - label: 'Description' + label: 'Description', + ngBindHtml: 'activity.description', + nosort: true, + searchable: false + }, + system_event: { + label: 'System event?', + searchOnly: true, + searchType: 'isnull', + sourceModel: 'user', + sourceField: 'username' } }, @@ -61,5 +83,14 @@ angular.module('StreamListDefinition', []) }, fieldActions: { + edit: { + label: 'View', + ngClick: "showDetail(\{\{ activity.id \}\})", + icon: 'icon-zoom-in', + "class": 'btn-default btn-xs', + awToolTip: 'View event details', + dataPlacement: 'top' + } } + }); \ No newline at end of file diff --git a/awx/ui/static/js/widgets/Stream.js b/awx/ui/static/js/widgets/Stream.js index e69aea74bb..f3fd56ec3f 100644 --- a/awx/ui/static/js/widgets/Stream.js +++ b/awx/ui/static/js/widgets/Stream.js @@ -27,7 +27,7 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti // Try not to overlap footer. Because stream is positioned absolute, the parent // doesn't resize correctly when stream is loaded. - $('#tab-content-container').css({ 'min-height': stream.height() }); + $('#tab-content-container').css({ 'min-height': stream.height() + 50 }); // Slide in stream stream.show('slide', {'direction': 'left'}, {'duration': 500, 'queue': false }); @@ -35,9 +35,10 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti } }]) - .factory('HideStream', [ 'ClearScope', function(ClearScope) { + .factory('HideStream', [ function() { return function() { // Remove the stream widget + var stream = $('#stream-container'); stream.hide('slide', {'direction': 'left'}, {'duration': 500, 'queue': false }); @@ -54,16 +55,140 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti } }]) + .factory('FixUrl', [ function() { + return function(u) { + return u.replace(/\/api\/v1\//,'/#/'); + } + }]) + + .factory('BuildUrl', [ function() { + return function(obj) { + var url = '/#/'; + switch(obj.base) { + case 'group': + case 'host': + url += 'home/' + obj.base + 's/?name=' + obj.name; + break; + case 'inventory': + url += 'inventories/' + obj.id; + break; + default: + url += obj.base + 's/' + obj.id; + } + return url; + } + }]) + + .factory('BuildDescription', ['FixUrl', 'BuildUrl', function(FixUrl, BuildUrl) { + return function(activity) { + var descr = ''; + if (activity.summary_fields.user) { + // this is a user transaction + var usr = FixUrl(activity.related.user); + descr += 'User ' + activity.summary_fields.user.username + ' '; + } + else { + descr += 'System '; + } + descr += activity.operation; + descr += (/e$/.test(activity.operation)) ? 'd ' : 'ed '; + if (activity.summary_fields.object2) { + descr += activity.summary_fields.object2.base + ' ' + + activity.summary_fields.object2.name + '' + [ (activity.operation == 'disassociate') ? ' from ' : ' to ']; + } + if (activity.summary_fields.object1) { + descr += activity.summary_fields.object1.base + ' ' + + activity.summary_fields.object1.name + ''; + } + return descr; + } + }]) + + .factory('ShowDetail', ['Rest', 'Alert', 'GenerateForm', 'ProcessErrors', 'GetBasePath', 'FormatDate', 'ActivityDetailForm', + function(Rest, Alert, GenerateForm, ProcessErrors, GetBasePath, FormatDate, ActivityDetailForm) { + return function(activity_id) { + + var generator = GenerateForm; + var form = ActivityDetailForm; + var scope; + + var url = GetBasePath('activity_stream') + activity_id + '/'; + + // Retrieve detail record and prepopulate the form + Rest.setUrl(url); + Rest.get() + .success( function(data, status, headers, config) { + // load up the form + var results = data; + + $('#form-modal').on('show.bs.modal', function (e) { + $('#form-modal-body').css({ + width:'auto', //probably not needed + height:'auto', //probably not needed + 'max-height':'100%' + }); + }); + + //var n = results['changes'].match(/\n/g); + //var rows = (n) ? n.length : 1; + //rows = (rows < 1) ? 3 : 10; + form.fields['changes'].rows = 10; + scope = generator.inject(form, { mode: 'edit', modal: true, related: false}); + generator.reset(); + for (var fld in form.fields) { + if (results[fld]) { + if (fld == 'timestamp') { + scope[fld] = FormatDate(new Date(results[fld])); + } + else { + scope[fld] = results[fld]; + } + } + } + if (results.summary_fields.object1) { + scope['object1_name'] = results.summary_fields.object1.name; + } + if (results.summary_fields.object2) { + scope['object2_name'] = results.summary_fields.object2.name; + } + scope['changes'] = JSON.stringify(results['changes'], null, '\t'); + scope.formModalAction = function() { + $('#form-modal').modal("hide"); + } + scope.formModalActionLabel = 'OK'; + scope.formModalCancelShow = false; + scope.formModalInfo = false; + //scope.formModalHeader = results.summary_fields.project.name + ' - SCM Status'; + $('#form-modal .btn-success').removeClass('btn-success').addClass('btn-none'); + $('#form-modal').addClass('skinny-modal'); + if (!scope.$$phase) { + scope.$digest(); + } + }) + .error( function(data, status, headers, config) { + $('#form-modal').modal("hide"); + ProcessErrors(scope, data, status, form, + { hdr: 'Error!', msg: 'Failed to retrieve activity: ' + activity_id + '. GET status: ' + status }); + }); + } + }]) + .factory('Stream', ['$rootScope', '$location', 'Rest', 'GetBasePath', 'ProcessErrors', 'Wait', 'StreamList', 'SearchInit', - 'PaginateInit', 'GenerateList', 'FormatDate', 'ShowStream', 'HideStream', + 'PaginateInit', 'GenerateList', 'FormatDate', 'ShowStream', 'HideStream', 'BuildDescription', 'FixUrl', 'ShowDetail', function($rootScope, $location, Rest, GetBasePath, ProcessErrors, Wait, StreamList, SearchInit, PaginateInit, GenerateList, - FormatDate, ShowStream, HideStream) { + FormatDate, ShowStream, HideStream, BuildDescription, FixUrl, ShowDetail) { return function(params) { var list = StreamList; var defaultUrl = GetBasePath('activity_stream'); var view = GenerateList; - + var base = $location.path().replace(/^\//,'').split('/')[0]; + + if (base !== 'home') { + var type = (base == 'inventories') ? 'inventory' : base.replace(/s$/,''); + defaultUrl += '?or__object1=' + type + '&or__object2=' + type; + } + // Push the current page onto browser histor. If user clicks back button, restore current page without // stream widget // window.history.pushState({}, "AnsibleWorks AWX", $location.path()); @@ -80,14 +205,16 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti }); scope.closeStream = function() { - HideStream(); - } + HideStream(); + } scope.refreshStream = function() { - scope.search(list.iterator); - } + scope.search(list.iterator); + } - function fixUrl(u) { return u.replace(/\/api\/v1\//,'/#/'); } + scope.showDetail = function(id) { + ShowDetail(id); + } if (scope.removePostRefresh) { scope.removePostRefresh(); @@ -97,24 +224,38 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti // Convert event_time date to local time zone cDate = new Date(scope['activities'][i].timestamp); scope['activities'][i].timestamp = FormatDate(cDate); + // Display username scope['activities'][i].user = (scope['activities'][i].summary_fields.user) ? scope['activities'][i].summary_fields.user.username : - 'System'; - if (scope['activities'][i].user !== 'System') { - scope['activities'][i].userLink = (scope['activities'][i].summary_fields.user) ? fixUrl(scope['activities'][i].related.user) : - ""; + 'system'; + if (scope['activities'][i].user !== 'system') { + // turn user into a link when not 'system' + scope['activities'][i].user = "" + + scope['activities'][i].user + ""; } - + // Objects var href; + var deleted = /^\_delete/; if (scope['activities'][i].summary_fields.object1) { - href = fixUrl(scope['activities'][i].related.object_1); - scope['activities'][i].objects = "" + scope['activities'][i].summary_fields.object1.name + ""; + if ( !deleted.test(scope['activities'][i].summary_fields.object1.name) ) { + href = FixUrl(scope['activities'][i].related.object1); + scope['activities'][i].objects = "" + scope['activities'][i].summary_fields.object1.name + ""; + } + else { + scope['activities'][i].objects = scope['activities'][i].summary_fields.object1.name; + } } if (scope['activities'][i].summary_fields.object2) { - href = fixUrl(scope['activities'][i].related.object_2); - scope['activities'][i].objects += ", " + scope['activities'][i].summary_fields.object2.name + ""; + if ( !deleted.test(scope['activities'][i].summary_fields.object2.name) ) { + href = FixUrl(scope['activities'][i].related.object2); + scope['activities'][i].objects += ", " + scope['activities'][i].summary_fields.object2.name + ""; + } + else { + scope['activities'][i].objects += scope['activities'][i].summary_fields.object2.name; + } } + scope['activities'][i].description = BuildDescription(scope['activities'][i]); } ShowStream(); }); @@ -122,8 +263,7 @@ angular.module('StreamWidget', ['RestServices', 'Utilities', 'StreamListDefiniti // Initialize search and paginate pieces and load data SearchInit({ scope: scope, set: list.name, list: list, url: defaultUrl }); PaginateInit({ scope: scope, list: list, url: defaultUrl }); - scope.search(list.iterator); - + scope.search(list.iterator); } }]); \ No newline at end of file diff --git a/awx/ui/templates/ui/index.html b/awx/ui/templates/ui/index.html index e3d5e6378a..28b6ce8dc5 100644 --- a/awx/ui/templates/ui/index.html +++ b/awx/ui/templates/ui/index.html @@ -75,6 +75,7 @@ +