diff --git a/awx/ui/client/legacy-styles/main-layout.less b/awx/ui/client/legacy-styles/main-layout.less index dc6722cc6e..99c865d737 100644 --- a/awx/ui/client/legacy-styles/main-layout.less +++ b/awx/ui/client/legacy-styles/main-layout.less @@ -21,6 +21,7 @@ body { padding-bottom: 50px; position: relative; background-color: @default-secondary-bg; + padding-top: 96px; } .container-fluid { @@ -61,7 +62,7 @@ body { } #content-container { - padding-bottom: 40px; + padding-bottom: 0px; } .group-breadcrumbs { diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 3a9f5eee2d..4bcfac154e 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -53,6 +53,7 @@ import organizations from './organizations/main'; import managementJobs from './management-jobs/main'; import jobDetail from './job-detail/main'; import workflowResults from './workflow-results/main'; +import jobResults from './job-results/main'; import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; import about from './about/main'; @@ -123,6 +124,7 @@ var tower = angular.module('Tower', [ footer.name, jobDetail.name, workflowResults.name, + jobResults.name, jobSubmission.name, notifications.name, standardOut.name, diff --git a/awx/ui/client/src/config.js b/awx/ui/client/src/config.js index b252faec17..bac59a282c 100644 --- a/awx/ui/client/src/config.js +++ b/awx/ui/client/src/config.js @@ -26,7 +26,6 @@ return { // custom_logo: true // load /var/lib/awx/public/static/assets/custom_console_logo.png as the login modal header. if false, will load the standard tower console logo // custom_login_info: "example notice" // have a notice displayed in the login modal for users. note that, as a security measure, custom html is not supported and will be escaped. - tooltip_delay: { show: 500, hide: 100 }, // Default number of milliseconds to delay displaying/hiding tooltips password_length: 8, // Minimum user password length. Set to 0 to not set a limit password_hasLowercase: true, // require a lowercase letter in the password diff --git a/awx/ui/client/src/job-detail/host-event/host-event.block.less b/awx/ui/client/src/job-detail/host-event/host-event.block.less index fc84890856..eafa9678fd 100644 --- a/awx/ui/client/src/job-detail/host-event/host-event.block.less +++ b/awx/ui/client/src/job-detail/host-event/host-event.block.less @@ -101,11 +101,11 @@ font-weight: 600; margin-bottom: 8px; } -.HostEvent .modal-body{ - max-height: 500px; - overflow-y: auto; - padding: 20px; -} +// .HostEvent .modal-body{ +// max-height: 500px; +// overflow-y: auto; +// padding: 20px; +// } .HostEvent-nav{ padding-top: 12px; padding-bottom: 12px; diff --git a/awx/ui/client/src/job-detail/job-detail.block.less b/awx/ui/client/src/job-detail/job-detail.block.less index a22cf4a8c2..c23b8d321d 100644 --- a/awx/ui/client/src/job-detail/job-detail.block.less +++ b/awx/ui/client/src/job-detail/job-detail.block.less @@ -2,7 +2,7 @@ @import '../shared/branding/colors.less'; @import '../shared/branding/colors.default.less'; -@import '../shared/layouts/one-plus-one.less'; +@import '../shared/layouts/one-plus-two.less'; @breakpoint-md: 1200px; @breakpoint-sm: 623px; @@ -21,7 +21,7 @@ } } .JobDetail{ - .OnePlusOne-container(100%, @breakpoint-md); + .OnePlusTwo-container(100%, @breakpoint-md); &.fullscreen { .JobDetail-rightSide { @@ -31,11 +31,11 @@ } .JobDetail-leftSide{ - .OnePlusOne-panel--left(100%, @breakpoint-md); + .OnePlusTwo-left--panel(100%, @breakpoint-md); } .JobDetail-rightSide{ - .OnePlusOne-panel--right(100%, @breakpoint-md); + .OnePlusTwo-right--panel(100%, @breakpoint-md); @media (max-width: @breakpoint-md - 1px) { padding-right: 15px; } @@ -88,51 +88,44 @@ } .JobDetail-resultRow{ - width: 50%; + width: 100%; display: flex; - @media screen and(max-width: @breakpoint-sm){ - width: 100%; - } + padding-bottom: 10px; + flex-wrap: wrap; +} + +.JobDetail-resultRow--variables { + flex-direction: column; } .JobDetail-resultRowLabel{ text-transform: uppercase; -} - -.JobDetail-resultRow label{ color: @default-interface-txt; font-size: 14px; font-weight: normal!important; - flex: 1 0 auto; + width: 30%; + margin-right: 20px; @media screen and(max-width: @breakpoint-md){ flex: 2.5 0 auto; } } -.JobDetail-resultRow--variables{ +.JobDetail-resultRowLabel--fullWidth { width: 100%; - display: flex; - flex-direction: column; - padding-left:15px; -} - -.JobDetail-extraVars{ - text-transform: none; -} - -.JobDetail-extraVarsLabel{ - margin-left:-15px; - padding-bottom: 15px; + margin-right: 0px; } .JobDetail-resultRowText{ - width: 40%; + width: ~"calc(70% - 20px)"; flex: 1 0 auto; - padding:0px 29px; text-transform: none; word-wrap: break-word; } +.JobDetail-resultRowText--fullWidth { + width: 100%; +} + .JobDetail-searchHeaderRow{ display: flex; flex-wrap: wrap; diff --git a/awx/ui/client/src/job-detail/job-detail.route.js b/awx/ui/client/src/job-detail/job-detail.route.js index abd45101a1..94088c126b 100644 --- a/awx/ui/client/src/job-detail/job-detail.route.js +++ b/awx/ui/client/src/job-detail/job-detail.route.js @@ -1,26 +1,91 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import { templateUrl } from '../shared/template-url/template-url.factory'; - -export default { - name: 'jobDetail', - url: '/jobs/{id: int}', - ncyBreadcrumb: { - parent: 'jobs', - label: "{{ job.id }} - {{ job.name }}" - }, - data: { - socket: { - "groups": { - "jobs": ["status_changed", "summary"], - "job_events": [] - } - } - }, - templateUrl: templateUrl('job-detail/job-detail'), - controller: 'JobDetailController' -}; +// <<<<<<< 4cf6a946a1aa14b7d64a8e1e8dabecfd3d056f27 +// //<<<<<<< bc59236851902d7c768aa26abdb7dc9c9dc27a5a +// /************************************************* +// * Copyright (c) 2016 Ansible, Inc. +// * +// * All Rights Reserved +// *************************************************/ +// +// // <<<<<<< a3d9eea2c9ddb4e16deec9ec38dea16bf37c559d +// // import { templateUrl } from '../shared/template-url/template-url.factory'; +// // +// // export default { +// // name: 'jobDetail', +// // url: '/jobs/{id: int}', +// // ncyBreadcrumb: { +// // parent: 'jobs', +// // label: "{{ job.id }} - {{ job.name }}" +// // }, +// // data: { +// // socket: { +// // "groups": { +// // "jobs": ["status_changed", "summary"], +// // "job_events": [] +// // } +// // } +// // }, +// // templateUrl: templateUrl('job-detail/job-detail'), +// // controller: 'JobDetailController' +// // }; +// // ======= +// // import {templateUrl} from '../shared/template-url/template-url.factory'; +// // +// // export default { +// // name: 'jobDetail', +// // url: '/jobs/:id', +// // ncyBreadcrumb: { +// // parent: 'jobs', +// // label: "{{ job.id }} - {{ job.name }}" +// // }, +// // socket: { +// // "groups":{ +// // "jobs": ["status_changed", "summary"], +// // "job_events": [] +// // } +// // }, +// // templateUrl: templateUrl('job-detail/job-detail'), +// // controller: 'JobDetailController' +// // }; +// //======= +// ======= +// >>>>>>> Rebase of devel (w/ channels) + socket rework for new job details +// // /************************************************* +// // * Copyright (c) 2016 Ansible, Inc. +// // * +// // * All Rights Reserved +// // *************************************************/ +// // +// // import {templateUrl} from '../shared/template-url/template-url.factory'; +// // +// // export default { +// // name: 'jobDetail', +// // url: '/jobs/:id', +// // ncyBreadcrumb: { +// // parent: 'jobs', +// // label: "{{ job.id }} - {{ job.name }}" +// // }, +// // socket: { +// // "groups":{ +// // "jobs": ["status_changed", "summary"], +// // "job_events": [] +// // } +// // }, +// // resolve: { +// // jobEventsSocket: ['Socket', '$rootScope', function(Socket, $rootScope) { +// // if (!$rootScope.event_socket) { +// // $rootScope.event_socket = Socket({ +// // scope: $rootScope, +// // endpoint: "job_events" +// // }); +// // $rootScope.event_socket.init(); +// // // returns should really be providing $rootScope.event_socket +// // // otherwise, we have to inject the entire $rootScope into the controller +// // return true; +// // } else { +// // return true; +// // } +// // }] +// // }, +// // templateUrl: templateUrl('job-detail/job-detail'), +// // controller: 'JobDetailController' +// // }; diff --git a/awx/ui/client/src/job-detail/main.js b/awx/ui/client/src/job-detail/main.js index 891bfe373b..628f537e43 100644 --- a/awx/ui/client/src/job-detail/main.js +++ b/awx/ui/client/src/job-detail/main.js @@ -4,21 +4,21 @@ * All Rights Reserved *************************************************/ -import route from './job-detail.route'; +// import route from './job-detail.route'; import controller from './job-detail.controller'; import service from './job-detail.service'; import hostEvents from './host-events/main'; -import hostEvent from './host-event/main'; +// import hostEvent from './host-event/main'; import hostSummary from './host-summary/main'; export default angular.module('jobDetail', [ hostEvents.name, - hostEvent.name, + // hostEvent.name, hostSummary.name ]) .controller('JobDetailController', controller) - .service('JobDetailService', service) - .run(['$stateExtender', function($stateExtender) { - $stateExtender.addState(route); - }]); + .service('JobDetailService', service); + // .run(['$stateExtender', function($stateExtender) { + // $stateExtender.addState(route); + // }]); diff --git a/awx/ui/client/src/job-results/event-queue.service.js b/awx/ui/client/src/job-results/event-queue.service.js new file mode 100644 index 0000000000..3737de2258 --- /dev/null +++ b/awx/ui/client/src/job-results/event-queue.service.js @@ -0,0 +1,251 @@ +/************************************************* +* Copyright (c) 2016 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + +export default ['jobResultsService', 'parseStdoutService', '$q', function(jobResultsService, parseStdoutService, $q){ + var val = {}; + + val = { + populateDefers: {}, + queue: {}, + // Get the count of the last event + getPreviousCount: function(counter, type) { + var countAttr; + + if (type === 'play') { + countAttr = 'playCount'; + } else if (type === 'task') { + countAttr = 'taskCount'; + } else { + countAttr = 'count'; + } + + var previousCount = $q.defer(); + + // iteratively find the last count + var findCount = function(counter) { + if (counter === 0) { + // if counter is 0, no count has been initialized + // initialize one! + + if (countAttr === 'count') { + previousCount.resolve({ + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 + }); + } else { + previousCount.resolve(0); + } + + } else if (val.queue[counter] && val.queue[counter][countAttr] !== undefined) { + // this event has a count, resolve! + previousCount.resolve(_.clone(val.queue[counter][countAttr])); + } else { + // this event doesn't have a count, decrement to the + // previous event and check it + findCount(counter - 1); + } + }; + + if (val.queue[counter - 1]) { + // if the previous event has been resolved, start the iterative + // get previous count process + findCount(counter - 1); + } else if (val.populateDefers[counter - 1]){ + // if the previous event has not been resolved, wait for it to + // be and then start the iterative get previous count process + val.populateDefers[counter - 1].promise.then(function() { + findCount(counter - 1); + }); + } + + return previousCount.promise; + }, + // munge the raw event from the backend into the event_queue's format + munge: function(event) { + var mungedEventDefer = $q.defer(); + + // basic data needed in the munged event + var mungedEvent = { + counter: event.counter, + id: event.id, + processed: false, + name: event.event_name, + changes: [] + }; + + // the interface for grabbing standard out is generalized and + // present across many types of events, so go ahead and check for + // updates to it + if (event.stdout) { + mungedEvent.stdout = parseStdoutService.parseStdout(event); + mungedEvent.changes.push('stdout'); + } + + // for different types of events, you need different types of data + if (event.event_name === 'playbook_on_start') { + mungedEvent.count = { + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 + }; + mungedEvent.startTime = event.modified; + mungedEvent.changes.push('count'); + mungedEvent.changes.push('startTime'); + } else if (event.event_name === 'playbook_on_play_start') { + val.getPreviousCount(mungedEvent.counter, "play") + .then(count => { + mungedEvent.playCount = count + 1; + mungedEvent.changes.push('playCount'); + }); + } else if (event.event_name === 'playbook_on_task_start') { + val.getPreviousCount(mungedEvent.counter, "task") + .then(count => { + mungedEvent.taskCount = count + 1; + mungedEvent.changes.push('taskCount'); + }); + } else if (event.event_name === 'runner_on_ok' || + event.event_name === 'runner_on_async_ok') { + val.getPreviousCount(mungedEvent.counter) + .then(count => { + mungedEvent.count = count; + mungedEvent.count.ok++; + mungedEvent.changes.push('count'); + }); + } else if (event.event_name === 'runner_on_skipped') { + val.getPreviousCount(mungedEvent.counter) + .then(count => { + mungedEvent.count = count; + mungedEvent.count.skipped++; + mungedEvent.changes.push('count'); + }); + } else if (event.event_name === 'runner_on_unreachable') { + val.getPreviousCount(mungedEvent.counter) + .then(count => { + mungedEvent.count = count; + mungedEvent.count.unreachable++; + mungedEvent.changes.push('count'); + }); + } else if (event.event_name === 'runner_on_error' || + event.event_name === 'runner_on_async_failed') { + val.getPreviousCount(mungedEvent.counter) + .then(count => { + mungedEvent.count = count; + mungedEvent.count.failed++; + mungedEvent.changes.push('count'); + }); + } else if (event.event_name === 'playbook_on_stats') { + // get the data for populating the host status bar + mungedEvent.count = jobResultsService + .getCountsFromStatsEvent(event.event_data); + mungedEvent.finishedTime = event.modified; + mungedEvent.changes.push('count'); + mungedEvent.changes.push('countFinished'); + mungedEvent.changes.push('finishedTime'); + } + + mungedEventDefer.resolve(mungedEvent); + + return mungedEventDefer.promise; + }, + // reinitializes the event queue value for the job results page + initialize: function() { + val.queue = {}; + val.populateDefers = {}; + }, + // populates the event queue + populate: function(event) { + // if a defer hasn't been set up for the event, + // set one up now + if (!val.populateDefers[event.counter]) { + val.populateDefers[event.counter] = $q.defer(); + } + + // make sure not to send duplicate events over to the + // controller + if (val.queue[event.counter] && + val.queue[event.counter].processed) { + val.populateDefers.reject("duplicate event: " + + event); + } + + if (!val.queue[event.counter]) { + var resolvePopulation = function(event) { + // to resolve, put the event on the queue and + // then resolve the deferred value + val.queue[event.counter] = event; + val.populateDefers[event.counter].resolve(event); + }; + + if (event.counter === 1) { + // for the first event, go ahead and munge and + // resolve + val.munge(event).then(event => { + resolvePopulation(event); + }); + } else { + // for all other events, you have to do some things + // to keep the event processing in the UI synchronous + + if (!val.populateDefers[event.counter - 1]) { + // first, if the previous event doesn't have + // a defer set up (this happens when websocket + // events are coming in and you need to make + // rest calls to catch up), go ahead and set a + // defer for the previous event + val.populateDefers[event.counter - 1] = $q.defer(); + } + + // you can start the munging process... + val.munge(event).then(event => { + // ...but wait until the previous event has + // been resolved before resolving this one and + // doing stuff in the ui (that's why we + // needed that previous conditional). + val.populateDefers[event.counter - 1].promise + .then(() => { + resolvePopulation(event); + }); + }); + } + } else { + // don't repopulate the event if it's already been added + // and munged either by rest or by websocket event + val.populateDefers[event.counter] + .resolve(val.queue[event.counter]); + } + + return val.populateDefers[event.counter].promise; + }, + // the event has been processed in the view and should be marked as + // completed in the queue + markProcessed: function(event) { + var process = function(event) { + // the event has now done it's work in the UI, record + // that! + val.queue[event.counter].processed = true; + }; + + if (!val.queue[event.counter]) { + // sometimes, the process is called in the controller and + // the event queue hasn't caught up and actually added + // the event to the queue yet. Wait until that happens + val.populateDefers[event.counter].promise + .finally(function() { + process(event); + }); + } else { + process(event); + } + } + }; + + return val; +}]; diff --git a/awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html b/awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html new file mode 100644 index 0000000000..7c744d2169 --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html @@ -0,0 +1,2 @@ + diff --git a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html b/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html new file mode 100644 index 0000000000..7f5b750eaa --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html @@ -0,0 +1,57 @@ + diff --git a/awx/ui/client/src/job-results/host-event/host-event.block.less b/awx/ui/client/src/job-results/host-event/host-event.block.less new file mode 100644 index 0000000000..f9eff87d5c --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event.block.less @@ -0,0 +1,152 @@ +@import "./client/src/shared/branding/colors.less"; +@import "./client/src/shared/branding/colors.default.less"; +@import "./client/src/shared/layouts/one-plus-two.less"; + +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -khtml-user-select: none; /* Konqueror */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + not supported by any browser */ +} + +@media screen and (min-width: 768px){ + .HostEvent .modal-dialog{ + width: 700px; + } +} +.HostEvent .CodeMirror{ + overflow-x: hidden; +} +.HostEvent-controls button.HostEvent-close{ + color: #FFFFFF; + text-transform: uppercase; + padding-left: 15px; + padding-right: 15px; + background-color: @default-link; + border-color: @default-link; + &:hover{ + background-color: @default-link-hov; + border-color: @default-link-hov; + } +} +.HostEvent-body{ + margin-bottom: 10px; +} +.HostEvent-tab { + color: @btn-txt; + background-color: @btn-bg; + font-size: 12px; + border: 1px solid @btn-bord; + height: 30px; + border-radius: 5px; + margin-right: 20px; + padding-left: 10px; + padding-right: 10px; + padding-bottom: 5px; + padding-top: 5px; + transition: background-color 0.2s; + text-transform: uppercase; + text-align: center; + white-space: nowrap; + .noselect; +} +.HostEvent-tab:hover { + color: @btn-txt; + background-color: @btn-bg-hov; + cursor: pointer; +} +.HostEvent-tab--selected{ + color: @btn-txt-sel!important; + background-color: @default-icon!important; + border-color: @default-icon!important; +} +.HostEvent-view--container{ + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; +} +.HostEvent .modal-footer{ + border: 0; + margin-top: 0px; + padding-top: 5px; +} +.HostEvent-controls{ + float: right; + button { + margin-left: 10px; + } +} +.HostEvent-status--ok{ + color: @green; +} +.HostEvent-status--unreachable{ + color: @unreachable; +} +.HostEvent-status--changed{ + color: @changed; +} +.HostEvent-status--failed{ + color: @default-err; +} +.HostEvent-status--skipped{ + color: @skipped; +} +.HostEvent-header{ + padding-bottom: 15px; +} +.HostEvent-title{ + color: @default-interface-txt; + font-weight: 600; + margin-bottom: 8px; +} +.HostEvent .modal-body{ + height: 480px; + padding: 20px; +} +.HostEvent-nav{ + padding-top: 12px; + padding-bottom: 12px; +} +.HostEvent-field{ + margin-bottom: 8px; + flex: 0 1 12em; +} +.HostEvent-field--label{ + text-transform: uppercase; + flex: 0 1 80px; + max-width: 80px; + font-size: 12px; + word-wrap: break-word; +} +.HostEvent-field{ + .OnePlusTwo-left--detailsRow; +} +.HostEvent-field--content{ + word-wrap: break-word; + max-width: 13em; + flex: 0 1 13em; +} +.HostEvent-details--left, .HostEvent-details--right{ + flex: 1 1 47%; +} +.HostEvent-details--left{ + margin-right: 40px; +} +.HostEvent-details--right{ + .HostEvent-field--label{ + flex: 0 1 25em; + } + .HostEvent-field--content{ + max-width: 15em; + flex: 0 1 15em; + align-self: flex-end; + } +} +.HostEvent-button:disabled { + pointer-events: all!important; +} diff --git a/awx/ui/client/src/job-results/host-event/host-event.controller.js b/awx/ui/client/src/job-results/host-event/host-event.controller.js new file mode 100644 index 0000000000..fcb0f62f73 --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event.controller.js @@ -0,0 +1,90 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + + export default + ['$stateParams', '$scope', '$state', 'Wait', 'JobDetailService', 'hostEvent', 'hostResults', 'parseStdoutService', + function($stateParams, $scope, $state, Wait, JobDetailService, hostEvent, hostResults, parseStdoutService){ + + $scope.processEventStatus = JobDetailService.processEventStatus; + $scope.hostResults = []; + // Avoid rendering objects in the details fieldset + // ng-if="processResults(value)" via host-event-details.partial.html + $scope.processResults = function(value){ + if (typeof value === 'object'){return false;} + else {return true;} + }; + $scope.isStdOut = function(){ + if ($state.current.name === 'jobDetails.host-event.stdout' || $state.current.name === 'jobDetaisl.histe-event.stderr'){ + return 'StandardOut-preContainer StandardOut-preContent'; + } + }; + /*ignore jslint start*/ + var initCodeMirror = function(el, data, mode){ + var container = document.getElementById(el); + var editor = CodeMirror.fromTextArea(container, { // jshint ignore:line + lineNumbers: true, + mode: mode + }); + editor.setSize("100%", 200); + editor.getDoc().setValue(data); + }; + /*ignore jslint end*/ + $scope.isActiveState = function(name){ + return $state.current.name === name; + }; + + $scope.getActiveHostIndex = function(){ + var result = $scope.hostResults.filter(function( obj ) { + return obj.id === $scope.event.id; + }); + return $scope.hostResults.indexOf(result[0]); + }; + + var init = function(){ + $scope.event = _.cloneDeep(hostEvent); + $scope.hostResults = hostResults; + $scope.json = JobDetailService.processJson(hostEvent); + + // grab standard out & standard error if present, and remove from the results displayed in the details panel + if (hostEvent.stdout){ + $scope.stdout = parseStdoutService.prettify(hostEvent.stdout, "unstyled"); + delete $scope.event.stdout; + } + if (hostEvent.stderr){ + $scope.stderr = hostEvent.stderr; + delete $scope.event.stderr; + } + // instantiate Codemirror + // try/catch pattern prevents the abstract-state controller from complaining about element being null + if ($state.current.name === 'jobDetail.host-event.json'){ + try{ + initCodeMirror('HostEvent-codemirror', JSON.stringify($scope.json, null, 4), {name: "javascript", json: true}); + } + catch(err){ + // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController + } + } + else if ($state.current.name === 'jobDetail.host-event.stdout'){ + try{ + initCodeMirror('HostEvent-codemirror', $scope.stdout, 'shell'); + } + catch(err){ + // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController + } + } + else if ($state.current.name === 'jobDetail.host-event.stderr'){ + try{ + initCodeMirror('HostEvent-codemirror', $scope.stderr, 'shell'); + } + catch(err){ + // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController + } + } + $('#HostEvent').modal('show'); + }; + init(); + }]; diff --git a/awx/ui/client/src/job-results/host-event/host-event.route.js b/awx/ui/client/src/job-results/host-event/host-event.route.js new file mode 100644 index 0000000000..04d08e1399 --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/host-event.route.js @@ -0,0 +1,58 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import { templateUrl } from '../../shared/template-url/template-url.factory'; + +var hostEventModal = { + name: 'jobDetail.host-event', + url: '/task/:taskId/host-event/:eventId', + controller: 'HostEventController', + templateUrl: templateUrl('job-results/host-event/host-event-modal'), + 'abstract': false, + resolve: { + hostEvent: ['JobDetailService', '$stateParams', function(JobDetailService, $stateParams) { + return JobDetailService.getRelatedJobEvents($stateParams.id, { + id: $stateParams.eventId + }).then(function(res) { + return res.data.results[0]; }); + }], + hostResults: ['JobDetailService', '$stateParams', function(JobDetailService, $stateParams) { + return JobDetailService.getJobEventChildren($stateParams.taskId).then(res => res.data.results); + }] + }, + onExit: function() { + // close the modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + $('#HostEvent').modal('hide'); + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + } +}; + +var hostEventJson = { + name: 'jobDetail.host-event.json', + url: '/json', + controller: 'HostEventController', + templateUrl: templateUrl('job-results/host-event/host-event-codemirror') +}; + +var hostEventStdout = { + name: 'jobDetail.host-event.stdout', + url: '/stdout', + controller: 'HostEventController', + templateUrl: templateUrl('job-results/host-event/host-event-codemirror') +}; + +var hostEventStderr = { + name: 'jobDetail.host-event.stderr', + url: '/stderr', + controller: 'HostEventController', + templateUrl: templateUrl('job-results/host-event/host-event-codemirror') +}; + + +export { hostEventJson, hostEventModal, hostEventStdout, hostEventStderr }; diff --git a/awx/ui/client/src/job-results/host-event/main.js b/awx/ui/client/src/job-results/host-event/main.js new file mode 100644 index 0000000000..76832b45e5 --- /dev/null +++ b/awx/ui/client/src/job-results/host-event/main.js @@ -0,0 +1,20 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + import {hostEventModal, + hostEventJson, hostEventStdout, hostEventStderr} from './host-event.route'; + import controller from './host-event.controller'; + + export default + angular.module('jobResults.hostEvent', []) + .controller('HostEventController', controller) + + .run(['$stateExtender', function($stateExtender){ + $stateExtender.addState(hostEventModal); + $stateExtender.addState(hostEventJson); + $stateExtender.addState(hostEventStdout); + $stateExtender.addState(hostEventStderr); + }]); diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less new file mode 100644 index 0000000000..893700ce8a --- /dev/null +++ b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less @@ -0,0 +1,80 @@ +@import '../../shared/branding/colors.default.less'; + +.HostStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; + margin-top: 10px; +} + +.HostStatusBar-ok, +.HostStatusBar-changed, +.HostStatusBar-unreachable, +.HostStatusBar-failures, +.HostStatusBar-skipped, +.HostStatusBar-noData { + height: 15px; + border-top: 5px solid @default-bg; + border-bottom: 5px solid @default-bg; +} + +.HostStatusBar-ok { + background-color: @default-succ; + display: flex; + flex: 0 0 auto; +} + +.HostStatusBar-changed { + background-color: @default-warning; + flex: 0 0 auto; +} + +.HostStatusBar-unreachable { + background-color: @default-unreachable; + flex: 0 0 auto; +} + +.HostStatusBar-failures { + background-color: @default-err; + flex: 0 0 auto; +} + +.HostStatusBar-skipped { + background-color: @default-link; + flex: 0 0 auto; +} + +.HostStatusBar-noData { + background-color: @default-icon-hov; + flex: 1 0 auto; +} + +.HostStatusBar-tooltipLabel { + text-transform: uppercase; + margin-right: 15px; +} + +.HostStatusBar-tooltipBadge { + border-radius: 5px; +} + +.HostStatusBar-tooltipBadge--ok { + background-color: @default-succ; +} + +.HostStatusBar-tooltipBadge--unreachable { + background-color: @default-unreachable; +} + +.HostStatusBar-tooltipBadge--skipped { + background-color: @default-link; +} + +.HostStatusBar-tooltipBadge--changed { + background-color: @default-warning; +} + +.HostStatusBar-tooltipBadge--failures { + background-color: @default-err; + +} diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js new file mode 100644 index 0000000000..f7fbd2e8f6 --- /dev/null +++ b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js @@ -0,0 +1,43 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +// import hostStatusBarController from './host-status-bar.controller'; +export default [ 'templateUrl', + function(templateUrl) { + return { + scope: true, + templateUrl: templateUrl('job-results/host-status-bar/host-status-bar'), + restrict: 'E', + // controller: standardOutLogController, + link: function(scope) { + // as count is changed by event data coming in, + // update the host status bar + scope.$watch('count', function(val) { + if (val) { + Object.keys(val).forEach(key => { + // reposition the hosts status bar by setting + // the various flex values to the count of + // those hosts + $(`.HostStatusBar-${key}`) + .css('flex', `${val[key]} 0 auto`); + + // set the tooltip to give how many hosts of + // each type + if (val[key] > 0) { + scope[`${key}CountTip`] = `${key}${val[key]}`; + } + }); + + // if there are any hosts that have finished, don't + // show default grey bar + scope.hostsFinished = (Object + .keys(val) + .filter(key => (val[key] > 0)).length > 0); + } + }); + } + }; +}]; diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html new file mode 100644 index 0000000000..24a9170bcd --- /dev/null +++ b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html @@ -0,0 +1,26 @@ +
+
+
+
+
+
+
+
diff --git a/awx/ui/client/src/job-results/host-status-bar/main.js b/awx/ui/client/src/job-results/host-status-bar/main.js new file mode 100644 index 0000000000..2b17a2e414 --- /dev/null +++ b/awx/ui/client/src/job-results/host-status-bar/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import hostStatusBar from './host-status-bar.directive'; + +export default + angular.module('hostStatusBarDirective', []) + .directive('hostStatusBar', hostStatusBar); diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less new file mode 100644 index 0000000000..cc34d11c2f --- /dev/null +++ b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less @@ -0,0 +1,240 @@ +@import '../../shared/branding/colors.default.less'; + +@breakpoint-md: 1200px; + +.JobResultsStdOut { + height: ~"calc(100% - 70px)"; +} + +.JobResultsStdOut-toolbar { + display: flex; + height: 38px; + margin-top: 15px; + border: 1px solid @default-list-header-bg; + border-bottom: 0px; + border-radius: 5px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; +} + +.JobResultsStdOut-toolbarNumberColumn { + background-color: @default-list-header-bg; + color: @b7grey; + flex: initial; + display: flex; + justify-content: space-between; + width: 70px; + padding-bottom: 0px; + padding-left: 8px; + padding-right: 8px; + padding-top: 10px; + border-top-left-radius: 5px; + z-index: 1; +} + +.JobResultsStdOut-expandAllButton { + height: 18px; + width: 18px; + padding-left: 4px; + padding-top: 1px; + border-radius: 50%; + background-color: @default-bg; + font-size: 12px; + cursor: pointer; +} + +.JobResultsStdOut-expandAllButton:hover .JobResultsStdOut-expandAllIcon, +.JobResultsStdOut-expandAllIcon:hover { + color: @default-data-txt; +} + +.JobResultsStdOut-toolbarStdoutColumn { + white-space: normal; + flex: 1; + display: flex; + justify-content: flex-end; + padding-right: 10px; + background-color: @default-no-items-bord; +} + +.JobResultsStdOut-followButton { + cursor: pointer; + width: 18px; + height: 18px; + width: 18px; + padding-left: 3.8px; + border-radius: 50%; + margin-top: 10px; + font-size: 12px; + background-color: @default-icon; + color: @default-border; +} + +.JobResultsStdOut-followIcon { + color: @default-border; +} + +.JobResultsStdOut-followButton:hover { + background-color: @default-icon-hov; +} + +.JobResultsStdOut-followButton:hover .JobResultsStdOut-followIcon, +.JobResultsStdOut-followIcon:hover { + color: @default-interface-txt; +} + +.JobResultsStdOut-followButton.is-engaged { + background-color: @default-link; + color: @default-bg; +} + +.JobResultsStdOut-followButton.is-engaged .JobResultsStdOut-followIcon { + color: @default-bg; +} + +.JobResultsStdOut-followButton.is-engaged:hover { + background-color: @default-icon; +} + +.JobResultsStdOut-followButton.is-engaged:hover .JobResultsStdOut-followIcon, +.JobResultsStdOut-followButton.is-engaged .JobResultsStdOut-followIcon:hover { + color: @default-border; +} + +.JobResultsStdOut-stdoutContainer { + height: ~"calc(100% - 48px)"; + background-color: @default-no-items-bord; + overflow-y: scroll; + overflow-x: hidden; +} + +.JobResultsStdOut-numberColumnPreload { + background-color: @default-list-header-bg; + width: 70px; + position: fixed; + top: 148px; + bottom: 20px; + margin-top: 65px; + margin-bottom: 65px; + +} + +.JobResultsStdOut-aLineOfStdOut { + display: flex; + font-family: Monaco, Menlo, Consolas, "Courier New", monospace; +} + +.JobResultsStdOut-lineExpander { + text-align: left; + padding-left: 11px; + margin-right: auto; +} + +.JobResultsStdOut-lineExpanderIcon { + font-size: 19px; + cursor: pointer; +} + +.JobResultsStdOut-lineExpanderIcon:hover { + color: @default-data-txt; +} + +.JobResultsStdOut-lineNumberColumn { + display: flex; + background-color: @default-list-header-bg; + text-align: right; + padding-right: 10px; + padding-top: 2px; + padding-bottom: 2px; + color: @b7grey; + width: 75px; + flex: initial; + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + z-index: 1; +} + +.JobResultsStdOut-stdoutColumn { + padding-left: 20px; + padding-top: 2px; + padding-bottom: 2px; + color: @default-interface-txt; + display: inline-block; + white-space: pre-wrap; + word-break: break-word; + width:100%; +} + +.JobResultsStdOut-aLineOfStdOut:hover, +.JobResultsStdOut-aLineOfStdOut:hover .JobResultsStdOut-lineNumberColumn { + background-color: @default-bg; +} + +.JobResultsStdOut-footer { + height: 10px; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; + background-color: @default-no-items-bord; + border: 1px solid @default-list-header-bg; + border-top: 0px; + border-radius: 5px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; +} + +.JobResultsStdOut-footerNumberColumn { + background-color: @default-list-header-bg; + width: 70px; + height: 100%; +} + +.JobResultsStdOut-followAnchor { + height: 20px; + width: 100%; +} + +.JobResultsStdOut-toTop { + margin-right: 20px; + color: #D7D7D7; + cursor: pointer; + text-align: right; + font-family: monaco; +} + +.JobResultsStdOut-toTop:hover { + color: @default-interface-txt; +} + +@media (max-width: @breakpoint-md) { + .JobResultsStdOut-numberColumnPreload { + display: none; + } + + .JobResultsStdOut-topAnchor { + position: static; + width: 100%; + top: -20px; + margin-top: -250px; + margin-bottom: 250px; + } + + .JobResultsStdOut-followAnchor { + height: 20px; + width: 100%; + border-left: 70px solid @default-list-header-bg; + } + + .JobResultsStdOut-stdoutContainer { + overflow-y: auto; + } + + .JobResultsStdOut-lineAnchor { + display: none !important; + } +} diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js new file mode 100644 index 0000000000..15d3e5e90c --- /dev/null +++ b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js @@ -0,0 +1,400 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +// import hostStatusBarController from './host-status-bar.controller'; +export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', + function(templateUrl, $timeout, $location, $anchorScroll) { + return { + scope: false, + templateUrl: templateUrl('job-results/job-results-stdout/job-results-stdout'), + restrict: 'E', + link: function(scope, element) { + + // utility function used to find the top visible line and + // parent header in the pane + // + // note that while this function is called when in mobile width + // the line anchor is not displayed in the css so calls + // to lineAnchor do nothing + var findTopLines = function() { + var $container = $('.JobResultsStdOut-stdoutContainer'); + + // get the first visible line's head element + var getHeadElement = function (line) { + var lineHasHeaderClass = !!(line + .hasClass("header_play") || + line.hasClass("header_task")); + var lineClassList; + var lineUUIDClass; + + if (lineHasHeaderClass) { + // find head element when the first visible + // line is a header + + lineClassList = line.attr("class") + .split(" "); + + // get the header class w task uuid... + lineUUIDClass = lineClassList + .filter(n => n + .indexOf("header_task_") > -1)[0]; + + // ...if that doesn't exist get the one + // w play uuid + if (!lineUUIDClass) { + lineUUIDClass = lineClassList + .filter(n => n. + indexOf("header_play_") > -1)[0]; + } + + // get the header line (not their might + // be more than one, so get the one with + // the actual header class) + // + // TODO it might be better in this case to just + // return `line` (less jumping with a cowsay + // case) + return $(".actual_header." + + lineUUIDClass); + } else { + // find head element when the first visible + // line is not a header + + lineClassList = line.attr("class") + .split(" "); + + // get the class w task uuid... + lineUUIDClass = lineClassList + .filter(n => n + .indexOf("task_") > -1)[0]; + + // ...if that doesn't exist get the one + // w play uuid + if (!lineUUIDClass) { + lineUUIDClass = lineClassList + .filter(n => n + .indexOf("play_") > -1)[0]; + } + + // get the header line (not their might + // be more than one, so get the one with + // the actual header class) + return $(".actual_header.header_" + + lineUUIDClass); + } + }; + + var visItem, + parentItem; + + var containerHeight = $container.height(); + var containerTop = $container.position().top; + var containerNetHeight = containerHeight + containerTop; + + // iterate through each line of standard out + $container.find('.JobResultsStdOut-aLineOfStdOut') + .each( function () { + var $this = $(this); + + var lineHeight = $this.height(); + var lineTop = $this.position().top; + var lineNetHeight = lineHeight + lineTop; + + // check to see if the line is the first visible + // line in the viewport... + if (lineNetHeight > containerTop && + lineTop < containerNetHeight) { + + // ...if it is, return the line number + // for this line + visItem = parseInt($($this + .children()[0]) + .text()); + + // as well as the line number for it's + // closest parent header line + var $head = getHeadElement($this); + parentItem = parseInt($($head + .children()[0]) + .text()); + + // stop iterating over the standard out + // lines once the first one has been + // found + return false; + } + }); + + return { + visLine: visItem, + parentVisLine: parentItem + }; + }; + + // find if window is initially mobile or desktop width + if (window.innerWidth <= 1200) { + scope.isMobile = true; + } else { + scope.isMobile = false; + } + // watch changes to the window size + $(window).resize(function() { + // and update the isMobile var accordingly + if (window.innerWidth <= 1200 && !scope.isMobile) { + scope.isMobile = true; + } else if (window.innerWidth > 1200 & scope.isMobile) { + scope.isMobile = false; + } + }); + + var lastScrollTop; + + var initScrollTop = function() { + lastScrollTop = 0; + }; + var scrollWatcher = function() { + var st = $(this).scrollTop(); + var netScroll = st + $(this).innerHeight(); + var fullHeight; + + if (st < lastScrollTop){ + // user up scrolled, so disengage follow + scope.followEngaged = false; + } + + if (scope.isMobile) { + // for mobile the height is the body of the entire + // page + fullHeight = $("body").height(); + } else { + // for desktop the height is the body of the + // stdoutContainer, minus the "^ TOP" indicator + fullHeight = $(this)[0].scrollHeight - 25; + } + + if(netScroll >= fullHeight) { + // user scrolled all the way to bottom, so engage + // follow + scope.followEngaged = true; + } + + // pane is now overflowed, show top indicator. + if (st > 0) { + scope.stdoutOverflowed = true; + } + + lastScrollTop = st; + }; + + // update scroll watchers when isMobile changes based on + // window resize + scope.$watch('isMobile', function(val) { + if (val === true) { + // make sure ^ TOP always shown for mobile + scope.stdoutOverflowed = true; + + // unbind scroll watcher on standard out container + $(".JobResultsStdOut-stdoutContainer") + .unbind('scroll'); + + // init scroll watcher on window + initScrollTop(); + $(window).on('scroll', scrollWatcher); + + } else if (val === false) { + // unbind scroll watcher on window + $(window).unbind('scroll'); + + // init scroll watcher on standard out container + initScrollTop(); + $(".JobResultsStdOut-stdoutContainer").on('scroll', + scrollWatcher); + } + }); + + // called to scroll to follow anchor + scope.followScroll = function() { + // a double-check to make sure the follow anchor is at + // the bottom of the standard out container + $(".JobResultsStdOut-followAnchor") + .appendTo(".JobResultsStdOut-stdoutContainer"); + + $location.hash('followAnchor'); + $anchorScroll(); + }; + + // called to scroll to top of standard out (when "^ TOP" is + // clicked) + scope.toTop = function() { + $location.hash('topAnchor'); + $anchorScroll(); + }; + + // called to scroll to the current line anchor + // when expand all/collapse all/filtering is engaged + // + // note that while this function can be called when in mobile + // width the line anchor is not displayed in the css so those + // calls do nothing + scope.lineAnchor = function() { + $location.hash('lineAnchor'); + $anchorScroll(); + }; + + // if following becomes active, go ahead and get to the bottom + // of the standard out pane + scope.$watch('followEngaged', function(val) { + // scroll to follow point if followEngaged is true + if (val) { + scope.followScroll(); + } + + // set up tooltip changes for not finsihed job + if (!scope.jobFinished) { + if (val) { + scope.followTooltip = "Currently following standard out as it comes in. Click to unfollow."; + } else { + scope.followTooltip = "Click to follow standard out as it comes in."; + } + } + }); + + // follow button ng-click function + scope.followToggleClicked = function() { + if (scope.jobFinished) { + // when the job is finished engage followScroll + scope.followScroll(); + } else { + // when the job is not finished toggle followEngaged + // which is watched above + scope.followEngaged = !scope.followEngaged; + } + }; + + // expand all/collapse all ng-click function + scope.toggleAllStdout = function(type) { + // find the top visible line in the container currently, + // as well as the header parent of that line + var topLines = findTopLines(); + + if (type === 'expand') { + // for expand prepend the lineAnchor to the visible + // line + $(".line_num_" + topLines.visLine) + .prepend($("#lineAnchor")); + } else { + // for collapse prepent the lineAnchor to the + // visible line's parent header + $(".line_num_" + topLines.parentVisLine) + .prepend($("#lineAnchor")); + } + + var expandClass; + if (type === 'expand') { + // for expand all, you'll need to find all the + // collapsed headers to expand them + expandClass = "fa-caret-right"; + } else { + // for collapse all, you'll need to find all the + // expanded headers to collapse them + expandClass = "fa-caret-down"; + } + + // find all applicable task headers that need to be + // toggled + element.find(".expanderizer--task."+expandClass) + .each((i, val) => { + // and trigger their expansion/collapsing + $timeout(function(){ + // TODO change to a direct call of the + // toggleLine function + angular.element(val).trigger('click'); + // TODO only call lineAnchor for those + // that are above the first visible line + scope.lineAnchor(); + }); + }); + + // find all applicable play headers that need to be + // toggled + element.find(".expanderizer--play."+expandClass) + .each((i, val) => { + // for collapse all, only collapse play + // headers that do not have children task + // headers + if(angular.element("." + + angular.element(val).attr("data-uuid")) + .find(".expanderizer--task") + .length === 0 || + type !== 'collapse') { + + // trigger their expansion/ + // collapsing + $timeout(function(){ + // TODO change to a direct + // call of the + // toggleLine function + angular.element(val) + .trigger('click'); + // TODO only call lineAnchor + // for those that are above + // the first visible line + scope.lineAnchor(); + }); + } + }); + }; + + // expand/collapse triangle ng-click function + scope.toggleLine = function($event, id) { + // if the section is currently expanded + if ($($event.currentTarget).hasClass("fa-caret-down")) { + // hide all the children lines + $(id).hide(); + + // and change the triangle for the header to collapse + $($event.currentTarget) + .removeClass("fa-caret-down"); + $($event.currentTarget) + .addClass("fa-caret-right"); + } else { + // if the section is currently collapsed + + // show all the children lines + $(id).show(); + + // and change the triangle for the header to expanded + $($event.currentTarget) + .removeClass("fa-caret-right"); + $($event.currentTarget) + .addClass("fa-caret-down"); + + // if the section you expanded is a play + if ($($event.currentTarget) + .hasClass("expanderizer--play")) { + // find all children task headers and + // expand them too + $("." + $($event.currentTarget) + .attr("data-uuid")) + .find(".expanderizer--task") + .each((i, val) => { + if ($(val) + .hasClass("fa-caret-right")) { + $timeout(function(){ + // TODO change to a + // direct call of the + // toggleLine function + angular.element(val) + .trigger('click'); + }); + } + }); + } + } + }; + } + }; +}]; diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html new file mode 100644 index 0000000000..0ba992b146 --- /dev/null +++ b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html @@ -0,0 +1,49 @@ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ ^ TOP +
+
+
+ +
diff --git a/awx/ui/client/src/job-results/job-results-stdout/main.js b/awx/ui/client/src/job-results/job-results-stdout/main.js new file mode 100644 index 0000000000..5fc583b9b1 --- /dev/null +++ b/awx/ui/client/src/job-results/job-results-stdout/main.js @@ -0,0 +1,11 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import jobResultsStdOut from './job-results-stdout.directive'; + +export default + angular.module('jobResultStdOutDirective', []) + .directive('jobResultsStandardOut', jobResultsStdOut); diff --git a/awx/ui/client/src/job-results/job-results.block.less b/awx/ui/client/src/job-results/job-results.block.less new file mode 100644 index 0000000000..37137dde6d --- /dev/null +++ b/awx/ui/client/src/job-results/job-results.block.less @@ -0,0 +1,125 @@ +@import '../shared/branding/colors.less'; +@import '../shared/branding/colors.default.less'; +@import '../shared/layouts/one-plus-two.less'; + +@breakpoint-md: 1200px; + +.JobResults { + .OnePlusTwo-container(100%, @breakpoint-md); + + &.fullscreen { + .JobResults-rightSide { + max-width: 100%; + } + } +} + +.JobResults-leftSide { + .OnePlusTwo-left--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; +} + +.JobResults-rightSide { + .OnePlusTwo-right--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; + + @media (max-width: @breakpoint-md - 1px) { + padding-right: 15px; + } +} + +.JobResults-detailsPanel{ + overflow-y: scroll; +} + +.JobResults-stdoutActionButton--active { + display: none; + visibility: hidden; + flex:none; + width:0px; + padding-right: 0px; +} + +.JobResults-panelHeader { + display: flex; + height: 30px; +} + +.JobResults-panelHeaderText { + color: @default-interface-txt; + flex: 1 0 auto; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} + +.JobResults-resultRow { + width: 100%; + display: flex; + padding-bottom: 10px; + flex-wrap: wrap; +} + +.JobResults-resultRow--variables { + flex-direction: column; +} + +.JobResults-resultRowLabel { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 14px; + font-weight: normal!important; + width: 30%; + margin-right: 20px; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} + +.JobResults-resultRowLabel--fullWidth { + width: 100%; + margin-right: 0px; +} + +.JobResults-resultRowText { + width: ~"calc(70% - 20px)"; + flex: 1 0 auto; + text-transform: none; + word-wrap: break-word; +} + +.JobResults-resultRowText--fullWidth { + width: 100%; +} + +.JobResults-statusResultIcon { + padding-left: 0px; + padding-right: 10px; +} + +.JobResults-badgeRow { + display: flex; + align-items: center; + margin-right: 5px; +} + +.JobResults-badgeTitle{ + color: @default-interface-txt; + font-size: 14px; + margin-right: 10px; + font-weight: normal; + text-transform: uppercase; + margin-left: 20px; +} + +@media (max-width: @breakpoint-md) { + .JobResults-detailsPanel { + overflow-y: auto; + } + + .JobResults-rightSide { + height: inherit; + } +} diff --git a/awx/ui/client/src/job-results/job-results.controller.js b/awx/ui/client/src/job-results/job-results.controller.js new file mode 100644 index 0000000000..af1622c148 --- /dev/null +++ b/awx/ui/client/src/job-results/job-results.controller.js @@ -0,0 +1,202 @@ +export default ['jobData', 'jobDataOptions', 'jobLabels', 'jobFinished', 'count', '$scope', 'ParseTypeChange', 'ParseVariableString', 'jobResultsService', 'eventQueue', '$compile', function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile) { + var getTowerLinks = function() { + var getTowerLink = function(key) { + if ($scope.job.related[key]) { + return '/#/' + $scope.job.related[key] + .split('api/v1/')[1]; + } else { + return null; + } + }; + + $scope.job_template_link = getTowerLink('job_template'); + $scope.created_by_link = getTowerLink('created_by'); + $scope.inventory_link = getTowerLink('inventory'); + $scope.project_link = getTowerLink('project'); + $scope.machine_credential_link = getTowerLink('credential'); + $scope.cloud_credential_link = getTowerLink('cloud_credential'); + $scope.network_credential_link = getTowerLink('network_credential'); + }; + + // uses options to set scope variables to their readable string + // value + var getTowerLabels = function() { + var getTowerLabel = function(key) { + if ($scope.jobOptions && $scope.jobOptions[key]) { + return $scope.jobOptions[key].choices + .filter(val => val[0] === $scope.job[key]) + .map(val => val[1])[0]; + } else { + return null; + } + }; + + $scope.status_label = getTowerLabel('status'); + $scope.type_label = getTowerLabel('job_type'); + $scope.verbosity_label = getTowerLabel('verbosity'); + }; + + var getTotalHostCount = function(count) { + return Object + .keys(count).reduce((acc, i) => acc += count[i], 0); + }; + + // put initially resolved request data on scope + $scope.job = jobData; + $scope.jobOptions = jobDataOptions.actions.GET; + $scope.labels = jobLabels; + $scope.jobFinished = jobFinished; + + // turn related api browser routes into tower routes + getTowerLinks(); + + // use options labels to manipulate display of details + getTowerLabels(); + + // set up a read only code mirror for extra vars + $scope.variables = ParseVariableString($scope.job.extra_vars); + $scope.parseType = 'yaml'; + ParseTypeChange({ scope: $scope, + field_id: 'pre-formatted-variables', + readOnly: true }); + + // Click binding for the expand/collapse button on the standard out log + $scope.stdoutFullScreen = false; + $scope.toggleStdoutFullscreen = function() { + $scope.stdoutFullScreen = !$scope.stdoutFullScreen; + }; + + $scope.deleteJob = function() { + jobResultsService.deleteJob($scope.job); + }; + + $scope.cancelJob = function() { + jobResultsService.cancelJob($scope.job); + }; + + $scope.relaunchJob = function() { + jobResultsService.relaunchJob($scope); + }; + + // get initial count from resolve + $scope.count = count.val; + $scope.hostCount = getTotalHostCount(count.val); + $scope.countFinished = count.countFinished; + + // if the job is still running engage following of the last line in the + // standard out pane + $scope.followEngaged = !$scope.jobFinished; + + // follow button for completed job should specify that the + // button will jump to the bottom of the standard out pane, + // not follow lines as they come in + if ($scope.jobFinished) { + $scope.followTooltip = "Jump to last line of standard out."; + } else { + $scope.followTooltip = "Currently following standard out as it comes in. Click to unfollow."; + } + + // EVENT STUFF BELOW + + // This is where the async updates to the UI actually happen. + // Flow is event queue munging in the service -> $scope setting in here + var processEvent = function(event) { + // put the event in the queue + eventQueue.populate(event).then(mungedEvent => { + // make changes to ui based on the event returned from the queue + if (mungedEvent.changes) { + mungedEvent.changes.forEach(change => { + // we've got a change we need to make to the UI! + // update the necessary scope and make the change + if (change === 'startTime' && !$scope.job.start) { + $scope.job.start = mungedEvent.startTime; + } + + if (change === 'count' && !$scope.countFinished) { + // for all events that affect the host count, + // update the status bar as well as the host + // count badge + $scope.count = mungedEvent.count; + $scope.hostCount = getTotalHostCount(mungedEvent + .count); + } + + if (change === 'playCount') { + $scope.playCount = mungedEvent.playCount; + } + + if (change === 'taskCount') { + $scope.taskCount = mungedEvent.taskCount; + } + + if (change === 'finishedTime' && !$scope.job.finished) { + $scope.job.finished = mungedEvent.finishedTime; + $scope.jobFinished = true; + $scope.followTooltip = "Jump to last line of standard out."; + } + + if (change === 'countFinished') { + // the playbook_on_stats event actually lets + // us know that we don't need to iteratively + // look at event to update the host counts + // any more. + $scope.countFinished = true; + } + + if(change === 'stdout'){ + // put stdout elements in stdout container + angular + .element(".JobResultsStdOut-stdoutContainer") + .append($compile(mungedEvent + .stdout)($scope)); + + // move the followAnchor to the bottom of the + // container + $(".JobResultsStdOut-followAnchor") + .appendTo(".JobResultsStdOut-stdoutContainer"); + + // if follow is engaged, + // scroll down to the followAnchor + if ($scope.followEngaged) { + $scope.followScroll(); + } + } + }); + } + + // the changes have been processed in the ui, mark it in the queue + eventQueue.markProcessed(event); + }); + }; + + // PULL! grab completed event data and process each event + // TODO: implement retry logic in case one of these requests fails + var getEvents = function(url) { + jobResultsService.getEvents(url) + .then(events => { + events.results.forEach(event => { + // get the name in the same format as the data + // coming over the websocket + event.event_name = event.event; + delete event.event; + processEvent(event); + }); + if (events.next) { + getEvents(events.next); + } + }); + }; + getEvents($scope.job.related.job_events); + + // Processing of job_events messages from the websocket + $scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) { + processEvent(data); + }); + + // Processing of job-status messages from the websocket + $scope.$on(`ws-jobs`, function(e, data) { + if (parseInt(data.unified_job_id, 10) === parseInt($scope.job.id,10)) { + $scope.job.status = data.status; + } + }); +}]; diff --git a/awx/ui/client/src/job-results/job-results.partial.html b/awx/ui/client/src/job-results/job-results.partial.html new file mode 100644 index 0000000000..904a7f73fb --- /dev/null +++ b/awx/ui/client/src/job-results/job-results.partial.html @@ -0,0 +1,522 @@ +
+
+
+ + +
+
+ + +
+
+ RESULTS +
+ + +
+ + + + + + + + + +
+
+ + +
+ + +
+ +
+ {{ job.started | longDate }} +
+
+ + +
+ +
+ {{ (job.finished | + longDate) || "Not Finished" }} +
+
+ + + + + +
+ +
+ {{ type_label }} +
+
+ + + + + + + + + + + +
+ +
+ {{ job.playbook }} +
+
+ + +
+ + +
+ + + + + + + + +
+ +
+ {{ job.forks }} +
+
+ + +
+ +
+ {{ job.limit }} +
+
+ + +
+ +
+ {{ verbosity_label }} +
+
+ + +
+ +
+ {{ job.job_tags }} +
+
+ + +
+ +
+ {{ job.skip_tags }} +
+
+ + +
+ + +
+ + +
+ +
+
+
+
+ {{ label }} +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+
+ + +
+
+ + + {{ job.name }} +
+ + +
+ +
+ Plays +
+ + {{ playCount || 0}} + + + +
+ Tasks +
+ + {{ taskCount || 0}} + + + +
+ Hosts +
+ + {{ hostCount || 0}} + + + +
+ Elapsed +
+ + {{ job.elapsed * 1000 | duration: "hh:mm:ss" }} + +
+ + +
+ + + + + + + + + +
+
+ + +
+ +
+
+
diff --git a/awx/ui/client/src/job-results/job-results.route.js b/awx/ui/client/src/job-results/job-results.route.js new file mode 100644 index 0000000000..1ea0cfbd92 --- /dev/null +++ b/awx/ui/client/src/job-results/job-results.route.js @@ -0,0 +1,158 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../shared/template-url/template-url.factory'; + +export default { + name: 'jobDetail', + url: '/jobs/:id', + ncyBreadcrumb: { + parent: 'jobs', + label: '{{ job.id }} - {{ job.name }}' + }, + data: { + socket: { + "groups":{ + "jobs": ["status_changed", "summary"], + "job_events": [] + } + } + }, + resolve: { + // the GET for the particular job + jobData: ['Rest', 'GetBasePath', '$stateParams', '$q', '$state', 'Alert', function(Rest, GetBasePath, $stateParams, $q, $state, Alert) { + Rest.setUrl(GetBasePath('jobs') + $stateParams.id); + var val = $q.defer(); + Rest.get() + .then(function(data) { + val.resolve(data.data); + }, function(data) { + val.reject(data); + + if (data.status === 404) { + Alert('Job Not Found', 'Cannot find job.', 'alert-info'); + } else if (data.status === 403) { + Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info'); + } + + $state.go('jobs'); + }); + return val.promise; + }], + // used to signify if job is completed or still running + jobFinished: ['jobData', function(jobData) { + if (jobData.finished) { + return true; + } else { + return false; + } + }], + // after the GET for the job, this helps us keep the status bar from + // flashing as rest data comes in. If the job is finished and + // there's a playbook_on_stats event, go ahead and resolve the count + // so you don't get that flashing! + count: ['jobData', 'jobResultsService', 'Rest', '$q', function(jobData, jobResultsService, Rest, $q) { + var defer = $q.defer(); + if (jobData.finished) { + // if the job is finished, grab the playbook_on_stats + // role to get the final count + Rest.setUrl(jobData.related.job_events + + "?event=playbook_on_stats"); + Rest.get() + .success(function(data) { + if(!data.results[0]){ + defer.resolve({val: { + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 + }, countFinished: false}); + } + else { + defer.resolve({ + val: jobResultsService + .getCountsFromStatsEvent(data + .results[0].event_data), + countFinished: true}); + } + }) + .error(function() { + defer.resolve({val: { + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 + }, countFinished: false}); + }); + } else { + // job isn't finished so just send an empty count and read + // from events + defer.resolve({val: { + ok: 0, + skipped: 0, + unreachable: 0, + failures: 0, + changed: 0 + }, countFinished: false}); + } + return defer.promise; + }], + // GET for the particular jobs labels to be displayed in the + // left-hand pane + jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + var getNext = function(data, arr, resolve) { + Rest.setUrl(data.next); + Rest.get() + .success(function (data) { + if (data.next) { + getNext(data, arr.concat(data.results), resolve); + } else { + resolve.resolve(arr.concat(data.results) + .map(val => val.name)); + } + }); + }; + + var seeMoreResolve = $q.defer(); + + Rest.setUrl(GetBasePath('jobs') + $stateParams.id + '/labels/'); + Rest.get() + .success(function(data) { + if (data.next) { + getNext(data, data.results, seeMoreResolve); + } else { + seeMoreResolve.resolve(data.results + .map(val => val.name)); + } + }); + + return seeMoreResolve.promise; + }], + // OPTIONS request for the job. Used to make things like the + // verbosity data in the left-hand pane prettier than just an + // integer + jobDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { + Rest.setUrl(GetBasePath('jobs') + $stateParams.id); + var val = $q.defer(); + Rest.options() + .then(function(data) { + val.resolve(data.data); + }, function(data) { + val.reject(data); + }); + return val.promise; + }], + // This clears out the event queue, otherwise it'd be full of events + // for previous job results the user had navigated to + eventQueueInit: ['eventQueue', function(eventQueue) { + eventQueue.initialize(); + }] + }, + templateUrl: templateUrl('job-results/job-results'), + controller: 'jobResultsController' +}; diff --git a/awx/ui/client/src/job-results/job-results.service.js b/awx/ui/client/src/job-results/job-results.service.js new file mode 100644 index 0000000000..07d442f3d7 --- /dev/null +++ b/awx/ui/client/src/job-results/job-results.service.js @@ -0,0 +1,197 @@ +/************************************************* +* Copyright (c) 2016 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + + +export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun) { + var val = { + // the playbook_on_stats event returns the count data in a weird format. + // format to what we need! + getCountsFromStatsEvent: function(event_data) { + var hosts = {}, + hostsArr; + + // iterate over the event_data and populate an object with hosts + // and their status data + Object.keys(event_data).forEach(key => { + // failed passes boolean not integer + if (key === "failed") { + // array of hosts from failed type + hostsArr = Object.keys(event_data[key]); + hostsArr.forEach(host => { + if (!hosts[host]) { + // host has not been added to hosts object + // add now + hosts[host] = {}; + } + + hosts[host][key] = event_data[key][host]; + }); + } else { + // array of hosts from each type ("changed", "dark", etc.) + hostsArr = Object.keys(event_data[key]); + hostsArr.forEach(host => { + if (!hosts[host]) { + // host has not been added to hosts object + // add now + hosts[host] = {}; + } + + if (!hosts[host][key]) { + // host doesn't have key + hosts[host][key] = 0; + } + hosts[host][key] += event_data[key][host]; + }); + } + }); + + // use the hosts data populate above to get the count + var count = { + ok : _.filter(hosts, function(o){ + return !o.failures && !o.changed && o.ok > 0; + }), + skipped : _.filter(hosts, function(o){ + return o.skipped > 0; + }), + unreachable : _.filter(hosts, function(o){ + return o.dark > 0; + }), + failures : _.filter(hosts, function(o){ + return o.failed === true; + }), + changed : _.filter(hosts, function(o){ + return o.changed > 0; + }) + }; + + // turn the count into an actual count, rather than a list of host + // names + Object.keys(count).forEach(key => { + count[key] = count[key].length; + }); + + return count; + }, + // rest call to grab previously complete job_events + getEvents: function(url) { + var val = $q.defer(); + + Rest.setUrl(url); + Rest.get() + .success(function(data) { + val.resolve({results: data.results, + next: data.next}); + }) + .error(function(obj, status) { + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not get job events. + Returned status: ${status}` + }); + val.reject(obj); + }); + + return val.promise; + }, + deleteJob: function(job) { + Prompt({ + hdr: 'Delete Job', + body: `
+ Are you sure you want to delete the job below? +
+
+ #${job.id} ${$filter('sanitize')(job.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(job.url); + Rest.destroy() + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + $state.go('jobs'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not delete job. + Returned status: ${status}` + }); + }); + }, + actionText: 'DELETE' + }); + }, + cancelJob: function(job) { + var doCancel = function() { + Rest.setUrl(job.url + 'cancel'); + Rest.post({}) + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not cancel job. + Returned status: ${status}` + }); + }); + }; + + Prompt({ + hdr: 'Cancel Job', + body: `
+ Are you sure you want to cancel the job below? +
+
+ #${job.id} ${$filter('sanitize')(job.name)} +
`, + action: function() { + Wait('start'); + Rest.setUrl(job.url + 'cancel'); + Rest.get() + .success(function(data) { + if (data.can_cancel === true) { + doCancel(); + } else { + $('#prompt-modal').modal('hide'); + ProcessErrors(null, data, null, null, { + hdr: 'Error!', + msg: `Job has completed, + unabled to be canceled.` + }); + } + }); + Rest.destroy() + .success(function() { + Wait('stop'); + $('#prompt-modal').modal('hide'); + }) + .error(function(obj, status) { + Wait('stop'); + $('#prompt-modal').modal('hide'); + ProcessErrors(null, obj, status, null, { + hdr: 'Error!', + msg: `Could not cancel job. + Returned status: ${status}` + }); + }); + }, + actionText: 'CANCEL' + }); + }, + relaunchJob: function(scope) { + InitiatePlaybookRun({ scope: scope, id: scope.job.id, + relaunch: true }); + } + }; + return val; +}]; diff --git a/awx/ui/client/src/job-results/main.js b/awx/ui/client/src/job-results/main.js new file mode 100644 index 0000000000..3747da4158 --- /dev/null +++ b/awx/ui/client/src/job-results/main.js @@ -0,0 +1,27 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import hostStatusBar from './host-status-bar/main'; +import jobResultsStdOut from './job-results-stdout/main'; +import hostEvent from './host-event/main'; + +import route from './job-results.route.js'; + +import jobResultsController from './job-results.controller'; + +import jobResultsService from './job-results.service'; +import eventQueueService from './event-queue.service'; +import parseStdoutService from './parse-stdout.service'; + +export default + angular.module('jobResults', [hostStatusBar.name, jobResultsStdOut.name, hostEvent.name]) + .run(['$stateExtender', function($stateExtender) { + $stateExtender.addState(route); + }]) + .controller('jobResultsController', jobResultsController) + .service('jobResultsService', jobResultsService) + .service('eventQueue', eventQueueService) + .service('parseStdoutService', parseStdoutService); diff --git a/awx/ui/client/src/job-results/parse-stdout.service.js b/awx/ui/client/src/job-results/parse-stdout.service.js new file mode 100644 index 0000000000..e75c364013 --- /dev/null +++ b/awx/ui/client/src/job-results/parse-stdout.service.js @@ -0,0 +1,208 @@ +/************************************************* +* Copyright (c) 2016 Ansible, Inc. +* +* All Rights Reserved +*************************************************/ + +export default ['$log', function($log){ + var val = { + // parses stdout string from api and formats various codes to the + // correct dom structure + prettify: function(line, unstyled){ + + line = line + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + // TODO: remove once Chris's fixes to the [K lines comes in + if (line.indexOf("[K") > -1) { + $log.error(line); + } + + if(!unstyled){ + // add span tags with color styling + line = line.replace(/u001b/g, ''); + + // ansi classes + line = line.replace(/\[1;31m/g, ''); + line = line.replace(/\[0;31m/g, ''); + line = line.replace(/\[0;32m/g, ''); + line = line.replace(/\[0;32m=/g, ''); + line = line.replace(/\[0;32m1/g, ''); + line = line.replace(/\[0;33m/g, ''); + line = line.replace(/\[0;34m/g, ''); + line = line.replace(/\[0;35m/g, ''); + line = line.replace(/\[0;36m/g, ''); + line = line.replace(/()\s/g, '$1'); + + //end span + line = line.replace(/\[0m/g, ''); + } else { + // For the host event modal in the standard out tab, + // the styling isn't necessary + line = line.replace(/u001b/g, ''); + + // ansi classes + line = line.replace(/\[1;31m/g, ''); + line = line.replace(/\[0;31m/g, ''); + line = line.replace(/\[0;32m/g, ''); + line = line.replace(/\[0;32m=/g, ''); + line = line.replace(/\[0;32m1/g, ''); + line = line.replace(/\[0;33m/g, ''); + line = line.replace(/\[0;34m/g, ''); + line = line.replace(/\[0;35m/g, ''); + line = line.replace(/\[0;36m/g, ''); + line = line.replace(/()\s/g, '$1'); + + //end span + line = line.replace(/\[0m/g, ''); + } + + return line; + }, + // adds anchor tags and tooltips to host status lines + getAnchorTags: function(event, line){ + if(event.event_name.indexOf("runner_") === -1){ + return line; + } + else{ + return `${line}`; + } + + }, + // this adds classes based on event data to the + // .JobResultsStdOut-aLineOfStdOut element + getLineClasses: function(event, line, lineNum) { + var string = ""; + if (event.event_name === "playbook_on_play_start") { + // play header classes + string += " header_play"; + string += " header_play_" + event.event_data.play_uuid; + + // give the actual header class to the line with the + // actual header info (think cowsay) + if (line.indexOf("PLAY") > -1) { + string += " actual_header"; + } + } else if (event.event_name === "playbook_on_task_start") { + // task header classes + string += " header_task"; + string += " header_task_" + event.event_data.task_uuid; + + // give the actual header class to the line with the + // actual header info (think cowsay) + if (line.indexOf("TASK") > -1 || + line.indexOf("RUNNING HANDLER") > -1) { + string += " actual_header"; + } + + // task headers also get classed by their parent play + // if applicable + if (event.event_data.play_uuid) { + string += " play_" + event.event_data.play_uuid; + } + } else { + // host status or debug line + + // these get classed by their parent play if applicable + if (event.event_data.play_uuid) { + string += " play_" + event.event_data.play_uuid; + } + // as well as their parent task if applicable + if (event.event_data.task_uuid) { + string += " task_" + event.event_data.task_uuid; + } + } + + // TODO: adding this line_num_XX class is hacky because the + // line number is availabe in children of this dom element + string += " line_num_" + lineNum; + + return string; + }, + // used to add expand/collapse icon next to line numbers of headers + getCollapseIcon: function(event, line) { + var clickClass, + expanderizerSpecifier; + + var emptySpan = ` +`; + + if ((event.event_name === "playbook_on_play_start" || + event.event_name === "playbook_on_task_start") && + line !== "") { + if (event.event_name === "playbook_on_play_start" && + line.indexOf("PLAY") > -1) { + // play header specific attrs + expanderizerSpecifier = "play"; + clickClass = "play_" + + event.event_data.play_uuid; + } else if (line.indexOf("TASK") > -1 || + line.indexOf("RUNNING HANDLER") > -1) { + // task header specific attrs + expanderizerSpecifier = "task"; + clickClass = "task_" + + event.event_data.task_uuid; + } else { + // header lines that don't have PLAY, TASK, + // or RUNNING HANDLER in them don't get + // expand icon. + // This provides cowsay support. + return emptySpan; + } + + + var expandDom = ` + + + +`; + // console.log(expandDom); + return expandDom; + } else { + // non-header lines don't get an expander + return emptySpan; + } + }, + getLineArr: function(event) { + return _ + .zip(_.range(event.start_line + 1, + event.end_line + 1), + event.stdout.replace("\t", " ").split("\r\n").slice(0, -1)); + }, + // public function that provides the parsed stdout line, given a + // job_event + parseStdout: function(event){ + // this utilizes the start/end lines and stdout blob + // to create an array in the format: + // [ + // [lineNum, lineText], + // [lineNum, lineText], + // ] + var lineArr = this.getLineArr(event); + + // this takes each `[lineNum: lineText]` element and calls the + // relevant helper functions in this service to build the + // parsed line of standard out + lineArr = lineArr + .map(lineArr => { + return ` +
+
${this.getCollapseIcon(event, lineArr[1])}${lineArr[0]}
+
${this.getAnchorTags(event, this.prettify(lineArr[1]))}
+
`; + }); + + // this joins all the lines for this job_event together and + // returns to the mungeEvent function + return lineArr.join(""); + } + }; + return val; +}]; diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index cd14cdb17a..433bbbcb78 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -492,7 +492,10 @@ angular.module('AWDirectives', ['RestServices', 'Utilities', 'JobsHelper']) .directive('awToolTip', [function() { return { link: function(scope, element, attrs) { - var delay = (attrs.delay !== undefined && attrs.delay !== null) ? attrs.delay : ($AnsibleConfig) ? $AnsibleConfig.tooltip_delay : { show: 500, hide: 100 }, + // if (attrs.class.indexOf("JobResultsStdOut") > -1) { + // debugger; + // } + var delay = { show: 200, hide: 0 }, placement, stateChangeWatcher; if (attrs.awTipPlacement) { diff --git a/awx/ui/client/src/shared/main.js b/awx/ui/client/src/shared/main.js index 8db9dd8172..5b68027dde 100644 --- a/awx/ui/client/src/shared/main.js +++ b/awx/ui/client/src/shared/main.js @@ -20,6 +20,7 @@ import templateUrl from './template-url/main'; import RestServices from '../rest/main'; import stateDefinitions from './stateDefinitions.factory'; import apiLoader from './api-loader'; +import 'angular-duration-format'; export default angular.module('shared', [listGenerator.name, @@ -36,6 +37,7 @@ angular.module('shared', [listGenerator.name, RestServices.name, apiLoader.name, require('angular-cookies'), + 'angular-duration-format' ]) .factory('stateDefinitions', stateDefinitions) .factory('lodashAsPromised', lodashAsPromised) diff --git a/awx/ui/npm-shrinkwrap.json b/awx/ui/npm-shrinkwrap.json index 882ba5a78e..240a18f337 100644 --- a/awx/ui/npm-shrinkwrap.json +++ b/awx/ui/npm-shrinkwrap.json @@ -86,6 +86,11 @@ "from": "leigh-johnson/angular-drag-and-drop-lists#1.4.0", "resolved": "git://github.com/leigh-johnson/angular-drag-and-drop-lists.git#4d32654ab7159689a7767b9be8fc85f9812ca5a8" }, + "angular-duration-format": { + "version": "1.0.1", + "from": "angular-duration-format@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/angular-duration-format/-/angular-duration-format-1.0.1.tgz" + }, "angular-filters": { "version": "1.1.2", "from": "angular-filters@>=1.1.2 <2.0.0", @@ -102,9 +107,9 @@ "resolved": "https://registry.npmjs.org/angular-gettext-tools/-/angular-gettext-tools-2.3.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -113,6 +118,11 @@ "from": "angular-md5@>=0.1.8 <0.2.0", "resolved": "https://registry.npmjs.org/angular-md5/-/angular-md5-0.1.10.tgz" }, + "angular-mocks": { + "version": "1.5.8", + "from": "angular-mocks@>=1.5.8 <2.0.0", + "resolved": "https://registry.npmjs.org/angular-mocks/-/angular-mocks-1.5.8.tgz" + }, "angular-moment": { "version": "0.10.3", "from": "angular-moment@>=0.10.1 <0.11.0", @@ -207,9 +217,9 @@ "resolved": "https://registry.npmjs.org/archiver/-/archiver-1.1.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -219,9 +229,9 @@ "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-1.3.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -311,9 +321,9 @@ "resolved": "https://registry.npmjs.org/async/-/async-2.1.2.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.14.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -333,9 +343,9 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" }, "autoprefixer": { - "version": "6.5.1", + "version": "6.5.3", "from": "autoprefixer@>=6.0.0 <7.0.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.5.1.tgz" + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.5.3.tgz" }, "aws-sign2": { "version": "0.6.0", @@ -358,9 +368,9 @@ "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.18.2.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" }, "source-map": { "version": "0.5.6", @@ -375,9 +385,9 @@ "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" }, "source-map": { "version": "0.5.6", @@ -397,9 +407,9 @@ "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -429,9 +439,9 @@ "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -445,6 +455,60 @@ "from": "babel-helpers@>=6.16.0 <7.0.0", "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.16.0.tgz" }, + "babel-istanbul": { + "version": "0.11.0", + "from": "babel-istanbul@>=0.11.0 <0.12.0", + "resolved": "https://registry.npmjs.org/babel-istanbul/-/babel-istanbul-0.11.0.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + }, + "source-map": { + "version": "0.4.4", + "from": "source-map@>=0.4.0 <0.5.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" + }, + "supports-color": { + "version": "3.1.2", + "from": "supports-color@>=3.1.0 <3.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" + }, + "wordwrap": { + "version": "1.0.0", + "from": "wordwrap@>=1.0.0 <1.1.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" + } + } + }, + "babel-loader": { + "version": "6.2.7", + "from": "babel-loader@>=6.2.4 <7.0.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-6.2.7.tgz", + "dependencies": { + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.1 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + } + } + }, "babel-messages": { "version": "6.8.0", "from": "babel-messages@>=6.8.0 <7.0.0", @@ -455,6 +519,11 @@ "from": "babel-plugin-check-es2015-constants@>=6.3.13 <7.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.8.0.tgz" }, + "babel-plugin-istanbul": { + "version": "2.0.3", + "from": "babel-plugin-istanbul@>=2.0.0 <3.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-2.0.3.tgz" + }, "babel-plugin-transform-es2015-arrow-functions": { "version": "6.8.0", "from": "babel-plugin-transform-es2015-arrow-functions@>=6.3.13 <7.0.0", @@ -471,9 +540,9 @@ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -582,15 +651,20 @@ "from": "babel-plugin-transform-strict-mode@>=6.18.0 <7.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.18.0.tgz" }, + "babel-preset-es2015": { + "version": "6.18.0", + "from": "babel-preset-es2015@>=6.9.0 <7.0.0", + "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.18.0.tgz" + }, "babel-register": { "version": "6.18.0", "from": "babel-register@>=6.18.0 <7.0.0", "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" }, "minimist": { "version": "0.0.8", @@ -615,9 +689,9 @@ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.16.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -627,9 +701,9 @@ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -639,9 +713,9 @@ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.18.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -691,9 +765,9 @@ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.0.tgz" }, "beeper": { - "version": "1.1.0", + "version": "1.1.1", "from": "beeper@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.0.tgz" + "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.1.tgz" }, "benchmark": { "version": "1.0.0", @@ -795,9 +869,9 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz" }, "browser-sync": { - "version": "2.17.5", + "version": "2.17.6", "from": "browser-sync@>=2.14.0 <3.0.0", - "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.17.5.tgz" + "resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.17.6.tgz" }, "browser-sync-client": { "version": "2.4.3", @@ -820,9 +894,9 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.4.0.tgz" }, "bs-recipes": { - "version": "1.2.3", - "from": "bs-recipes@1.2.3", - "resolved": "https://registry.npmjs.org/bs-recipes/-/bs-recipes-1.2.3.tgz" + "version": "1.3.2", + "from": "bs-recipes@1.3.2", + "resolved": "https://registry.npmjs.org/bs-recipes/-/bs-recipes-1.3.2.tgz" }, "buffer": { "version": "4.9.1", @@ -872,9 +946,9 @@ } }, "caniuse-db": { - "version": "1.0.30000576", - "from": "caniuse-db@>=1.0.30000554 <2.0.0", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000576.tgz" + "version": "1.0.30000581", + "from": "caniuse-db@>=1.0.30000578 <2.0.0", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000581.tgz" }, "caseless": { "version": "0.11.0", @@ -947,9 +1021,9 @@ "resolved": "https://registry.npmjs.org/combine-lists/-/combine-lists-1.0.1.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.5.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -1483,6 +1557,11 @@ "from": "expand-range@>=1.8.1 <2.0.0", "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz" }, + "expose-loader": { + "version": "0.7.1", + "from": "expose-loader@>=0.7.1 <0.8.0", + "resolved": "https://registry.npmjs.org/expose-loader/-/expose-loader-0.7.1.tgz" + }, "express": { "version": "2.5.11", "from": "express@>=2.5.0 <2.6.0", @@ -2343,9 +2422,9 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz" }, "globals": { - "version": "9.12.0", + "version": "9.13.0", "from": "globals@>=9.0.0 <10.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.12.0.tgz" + "resolved": "https://registry.npmjs.org/globals/-/globals-9.13.0.tgz" }, "globule": { "version": "1.1.0", @@ -2369,6 +2448,33 @@ "from": "graceful-readlink@>=1.0.0", "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" }, + "grunt": { + "version": "1.0.1", + "from": "grunt@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.0.1.tgz", + "dependencies": { + "glob": { + "version": "7.0.6", + "from": "glob@>=7.0.0 <7.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz" + }, + "js-yaml": { + "version": "3.5.5", + "from": "js-yaml@>=3.5.2 <3.6.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.5.5.tgz" + }, + "rimraf": { + "version": "2.2.8", + "from": "rimraf@>=2.2.8 <2.3.0", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.2.8.tgz" + } + } + }, + "grunt-angular-gettext": { + "version": "2.3.0", + "from": "grunt-angular-gettext@>=2.2.3 <3.0.0", + "resolved": "https://registry.npmjs.org/grunt-angular-gettext/-/grunt-angular-gettext-2.3.0.tgz" + }, "grunt-browser-sync": { "version": "2.2.0", "from": "grunt-browser-sync@>=2.2.0 <3.0.0", @@ -2379,6 +2485,64 @@ "from": "grunt-cli@>=1.2.0 <2.0.0", "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz" }, + "grunt-concurrent": { + "version": "2.3.1", + "from": "grunt-concurrent@>=2.3.0 <3.0.0", + "resolved": "https://registry.npmjs.org/grunt-concurrent/-/grunt-concurrent-2.3.1.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.2.1 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + } + } + }, + "grunt-contrib-clean": { + "version": "1.0.0", + "from": "grunt-contrib-clean@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-clean/-/grunt-contrib-clean-1.0.0.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.5.2 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + } + } + }, + "grunt-contrib-concat": { + "version": "1.0.1", + "from": "grunt-contrib-concat@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-concat/-/grunt-contrib-concat-1.0.1.tgz", + "dependencies": { + "source-map": { + "version": "0.5.6", + "from": "source-map@>=0.5.3 <0.6.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" + } + } + }, + "grunt-contrib-copy": { + "version": "1.0.0", + "from": "grunt-contrib-copy@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-copy/-/grunt-contrib-copy-1.0.0.tgz" + }, + "grunt-contrib-jshint": { + "version": "1.0.0", + "from": "grunt-contrib-jshint@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-1.0.0.tgz" + }, + "grunt-contrib-less": { + "version": "1.4.0", + "from": "grunt-contrib-less@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-contrib-less/-/grunt-contrib-less-1.4.0.tgz", + "dependencies": { + "lodash": { + "version": "4.17.1", + "from": "lodash@>=4.8.2 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" + } + } + }, "grunt-contrib-watch": { "version": "1.0.0", "from": "grunt-contrib-watch@>=1.0.0 <2.0.0", @@ -2391,6 +2555,11 @@ } } }, + "grunt-extract-sourcemap": { + "version": "0.1.19", + "from": "grunt-extract-sourcemap@>=0.1.18 <0.2.0", + "resolved": "https://registry.npmjs.org/grunt-extract-sourcemap/-/grunt-extract-sourcemap-0.1.19.tgz" + }, "grunt-known-options": { "version": "1.1.0", "from": "grunt-known-options@>=1.1.0 <1.2.0", @@ -2430,10 +2599,34 @@ } } }, + "grunt-newer": { + "version": "1.2.0", + "from": "grunt-newer@>=1.2.0 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-newer/-/grunt-newer-1.2.0.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.5.2 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + } + } + }, + "grunt-webpack": { + "version": "1.0.18", + "from": "grunt-webpack@>=1.0.11 <2.0.0", + "resolved": "https://registry.npmjs.org/grunt-webpack/-/grunt-webpack-1.0.18.tgz", + "dependencies": { + "lodash": { + "version": "4.17.1", + "from": "lodash@>=4.7.0 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" + } + } + }, "handlebars": { - "version": "4.0.5", + "version": "4.0.6", "from": "handlebars@>=4.0.0 <4.1.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.5.tgz", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.6.tgz", "dependencies": { "async": { "version": "1.5.2", @@ -2579,9 +2772,9 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz" }, "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.16.2 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } }, @@ -2620,6 +2813,18 @@ "from": "immutable@3.8.1", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.1.tgz" }, + "imports-loader": { + "version": "0.6.5", + "from": "imports-loader@>=0.6.5 <0.7.0", + "resolved": "https://registry.npmjs.org/imports-loader/-/imports-loader-0.6.5.tgz", + "dependencies": { + "source-map": { + "version": "0.1.43", + "from": "source-map@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" + } + } + }, "indent-string": { "version": "2.1.0", "from": "indent-string@>=2.1.0 <3.0.0", @@ -2663,9 +2868,9 @@ "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz" }, "invariant": { - "version": "2.2.1", + "version": "2.2.2", "from": "invariant@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.1.tgz" + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz" }, "invert-kv": { "version": "1.0.0", @@ -2845,9 +3050,14 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.0.tgz" }, "istanbul-lib-instrument": { - "version": "1.2.0", + "version": "1.3.0", "from": "istanbul-lib-instrument@>=1.1.4 <2.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.2.0.tgz" + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-1.3.0.tgz" + }, + "jasmine-core": { + "version": "2.5.2", + "from": "jasmine-core@>=2.4.1 <3.0.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.5.2.tgz" }, "javascript-detect-element-resize": { "version": "0.5.3", @@ -2885,9 +3095,9 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-2.0.0.tgz" }, "js-yaml": { - "version": "3.6.1", + "version": "3.7.0", "from": "js-yaml@>=3.2.7 <4.0.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz" + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz" }, "jsbn": { "version": "0.1.0", @@ -2904,6 +3114,35 @@ "from": "jsesc@>=1.3.0 <2.0.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz" }, + "jshint": { + "version": "2.9.4", + "from": "jshint@>=2.9.4 <3.0.0", + "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.9.4.tgz", + "dependencies": { + "lodash": { + "version": "3.7.0", + "from": "lodash@>=3.7.0 <3.8.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.7.0.tgz" + } + } + }, + "jshint-loader": { + "version": "0.8.3", + "from": "jshint-loader@>=0.8.3 <0.9.0", + "resolved": "https://registry.npmjs.org/jshint-loader/-/jshint-loader-0.8.3.tgz", + "dependencies": { + "strip-json-comments": { + "version": "0.1.3", + "from": "strip-json-comments@>=0.1.0 <0.2.0", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz" + } + } + }, + "jshint-stylish": { + "version": "2.2.1", + "from": "jshint-stylish@>=2.2.0 <3.0.0", + "resolved": "https://registry.npmjs.org/jshint-stylish/-/jshint-stylish-2.2.1.tgz" + }, "json-schema": { "version": "0.2.3", "from": "json-schema@0.2.3", @@ -2944,6 +3183,178 @@ "from": "jstimezonedetect@1.0.5", "resolved": "https://registry.npmjs.org/jstimezonedetect/-/jstimezonedetect-1.0.5.tgz" }, + "karma": { + "version": "1.3.0", + "from": "karma@>=1.1.2 <2.0.0", + "resolved": "https://registry.npmjs.org/karma/-/karma-1.3.0.tgz", + "dependencies": { + "accepts": { + "version": "1.1.4", + "from": "accepts@1.1.4", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.1.4.tgz" + }, + "base64-arraybuffer": { + "version": "0.1.2", + "from": "base64-arraybuffer@0.1.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.2.tgz" + }, + "component-emitter": { + "version": "1.2.0", + "from": "component-emitter@1.2.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.0.tgz" + }, + "engine.io": { + "version": "1.6.10", + "from": "engine.io@1.6.10", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.6.10.tgz" + }, + "engine.io-client": { + "version": "1.6.9", + "from": "engine.io-client@1.6.9", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.6.9.tgz", + "dependencies": { + "component-emitter": { + "version": "1.1.2", + "from": "component-emitter@1.1.2", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz" + } + } + }, + "engine.io-parser": { + "version": "1.2.4", + "from": "engine.io-parser@1.2.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.2.4.tgz", + "dependencies": { + "has-binary": { + "version": "0.1.6", + "from": "has-binary@0.1.6", + "resolved": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.6.tgz" + } + } + }, + "isarray": { + "version": "0.0.1", + "from": "isarray@0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" + }, + "mime": { + "version": "1.3.4", + "from": "mime@>=1.3.4 <2.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz" + }, + "mime-db": { + "version": "1.12.0", + "from": "mime-db@>=1.12.0 <1.13.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.12.0.tgz" + }, + "mime-types": { + "version": "2.0.14", + "from": "mime-types@>=2.0.4 <2.1.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.0.14.tgz" + }, + "negotiator": { + "version": "0.4.9", + "from": "negotiator@0.4.9", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.4.9.tgz" + }, + "socket.io": { + "version": "1.4.7", + "from": "socket.io@1.4.7", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.4.7.tgz" + }, + "socket.io-client": { + "version": "1.4.6", + "from": "socket.io-client@1.4.6", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.4.6.tgz" + }, + "source-map": { + "version": "0.5.6", + "from": "source-map@>=0.5.3 <0.6.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" + }, + "ws": { + "version": "1.0.1", + "from": "ws@1.0.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-1.0.1.tgz" + } + } + }, + "karma-chrome-launcher": { + "version": "1.0.1", + "from": "karma-chrome-launcher@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-1.0.1.tgz" + }, + "karma-coverage": { + "version": "1.1.1", + "from": "karma-coverage@>=1.1.1 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-1.1.1.tgz", + "dependencies": { + "source-map": { + "version": "0.5.6", + "from": "source-map@>=0.5.1 <0.6.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" + } + } + }, + "karma-firefox-launcher": { + "version": "1.0.0", + "from": "karma-firefox-launcher@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-1.0.0.tgz" + }, + "karma-html2js-preprocessor": { + "version": "1.1.0", + "from": "karma-html2js-preprocessor@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-html2js-preprocessor/-/karma-html2js-preprocessor-1.1.0.tgz" + }, + "karma-jasmine": { + "version": "1.0.2", + "from": "karma-jasmine@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-1.0.2.tgz" + }, + "karma-junit-reporter": { + "version": "1.1.0", + "from": "karma-junit-reporter@>=1.1.0 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-1.1.0.tgz" + }, + "karma-phantomjs-launcher": { + "version": "1.0.2", + "from": "karma-phantomjs-launcher@>=1.0.2 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-phantomjs-launcher/-/karma-phantomjs-launcher-1.0.2.tgz", + "dependencies": { + "lodash": { + "version": "4.17.1", + "from": "lodash@>=4.0.1 <5.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" + } + } + }, + "karma-sauce-launcher": { + "version": "1.1.0", + "from": "karma-sauce-launcher@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-sauce-launcher/-/karma-sauce-launcher-1.1.0.tgz" + }, + "karma-sourcemap-loader": { + "version": "0.3.7", + "from": "karma-sourcemap-loader@>=0.3.7 <0.4.0", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.7.tgz" + }, + "karma-webpack": { + "version": "1.8.0", + "from": "karma-webpack@>=1.8.0 <2.0.0", + "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-1.8.0.tgz", + "dependencies": { + "async": { + "version": "0.9.2", + "from": "async@>=0.9.0 <0.10.0", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz" + }, + "source-map": { + "version": "0.1.43", + "from": "source-map@>=0.1.41 <0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" + } + } + }, "kew": { "version": "0.7.0", "from": "kew@>=0.7.0 <0.8.0", @@ -3011,6 +3422,11 @@ } } }, + "less-plugin-autoprefix": { + "version": "1.5.1", + "from": "less-plugin-autoprefix@>=1.4.2 <2.0.0", + "resolved": "https://registry.npmjs.org/less-plugin-autoprefix/-/less-plugin-autoprefix-1.5.1.tgz" + }, "levn": { "version": "0.3.0", "from": "levn@>=0.3.0 <0.4.0", @@ -3026,6 +3442,16 @@ "from": "livereload-js@>=2.2.0 <3.0.0", "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.2.2.tgz" }, + "load-grunt-configs": { + "version": "1.0.0", + "from": "load-grunt-configs@>=1.0.0 <2.0.0", + "resolved": "https://registry.npmjs.org/load-grunt-configs/-/load-grunt-configs-1.0.0.tgz" + }, + "load-grunt-tasks": { + "version": "3.5.2", + "from": "load-grunt-tasks@>=3.5.0 <4.0.0", + "resolved": "https://registry.npmjs.org/load-grunt-tasks/-/load-grunt-tasks-3.5.2.tgz" + }, "load-json-file": { "version": "1.1.0", "from": "load-json-file@>=1.0.0 <2.0.0", @@ -3186,9 +3612,9 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" }, "moment": { - "version": "2.15.2", + "version": "2.16.0", "from": "moment@>=2.10.2 <3.0.0", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.15.2.tgz" + "resolved": "https://registry.npmjs.org/moment/-/moment-2.16.0.tgz" }, "ms": { "version": "0.7.1", @@ -3760,9 +4186,9 @@ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz" }, "readable-stream": { - "version": "2.1.5", + "version": "2.2.2", "from": "readable-stream@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz" + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.2.tgz" }, "readdirp": { "version": "2.1.0", @@ -3797,9 +4223,9 @@ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz" }, "regenerate": { - "version": "1.3.1", + "version": "1.3.2", "from": "regenerate@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.1.tgz" + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.2.tgz" }, "regenerator-runtime": { "version": "0.9.6", @@ -4174,9 +4600,9 @@ } }, "statuses": { - "version": "1.3.0", + "version": "1.3.1", "from": "statuses@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz" + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" }, "stream-browserify": { "version": "1.0.0", @@ -4302,6 +4728,11 @@ } } }, + "time-grunt": { + "version": "1.4.0", + "from": "time-grunt@>=1.4.0 <2.0.0", + "resolved": "https://registry.npmjs.org/time-grunt/-/time-grunt-1.4.0.tgz" + }, "time-zone": { "version": "0.1.0", "from": "time-zone@>=0.1.0 <0.2.0", @@ -4604,6 +5035,33 @@ } } }, + "webpack": { + "version": "1.13.3", + "from": "webpack@>=1.13.1 <2.0.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.13.3.tgz", + "dependencies": { + "async": { + "version": "1.5.2", + "from": "async@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" + }, + "minimist": { + "version": "0.0.8", + "from": "minimist@0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" + }, + "mkdirp": { + "version": "0.5.1", + "from": "mkdirp@>=0.5.0 <0.6.0", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz" + }, + "supports-color": { + "version": "3.1.2", + "from": "supports-color@>=3.1.0 <4.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" + } + } + }, "webpack-core": { "version": "0.6.8", "from": "webpack-core@>=0.6.0 <0.7.0", @@ -4666,9 +5124,9 @@ "resolved": "https://registry.npmjs.org/weinre/-/weinre-2.0.0-pre-I0Z7U9OV.tgz" }, "which": { - "version": "1.2.11", + "version": "1.2.12", "from": "which@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.11.tgz" + "resolved": "https://registry.npmjs.org/which/-/which-1.2.12.tgz" }, "which-module": { "version": "1.0.0", @@ -4765,9 +5223,9 @@ "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-1.1.0.tgz", "dependencies": { "lodash": { - "version": "4.16.6", + "version": "4.17.1", "from": "lodash@>=4.8.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.16.6.tgz" + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.1.tgz" } } } diff --git a/awx/ui/package.json b/awx/ui/package.json index 4cba5b9df9..737eaed35c 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -84,6 +84,7 @@ "angular-codemirror": "chouseknecht/angular-codemirror#1.0.4", "angular-cookies": "^1.4.3", "angular-drag-and-drop-lists": "leigh-johnson/angular-drag-and-drop-lists#1.4.0", + "angular-duration-format": "^1.0.1", "angular-gettext": "^2.3.5", "angular-md5": "^0.1.8", "angular-moment": "^0.10.1", diff --git a/awx/ui/tests/spec/job-results/job-results.controller-test.js b/awx/ui/tests/spec/job-results/job-results.controller-test.js new file mode 100644 index 0000000000..11e5275cd2 --- /dev/null +++ b/awx/ui/tests/spec/job-results/job-results.controller-test.js @@ -0,0 +1,559 @@ +'use strict'; + +describe('Controller: jobResultsController', () => { + // Setup + let jobResultsController; + + let jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, eventResolve, populateResolve, $rScope, q; + + jobData = { + related: {} + }; + jobDataOptions = { + actions: { + get: {} + } + }; + jobLabels = {}; + jobFinished = true; + count = { + val: {}, + countFinished: false + }; + eventResolve = { + results: [] + }; + populateResolve = {}; + + let provideVals = () => { + angular.mock.module('jobResults', ($provide) => { + ParseTypeChange = jasmine.createSpy('ParseTypeChange'); + ParseVariableString = jasmine.createSpy('ParseVariableString'); + jobResultsService = jasmine.createSpyObj('jobResultsService', [ + 'deleteJob', + 'cancelJob', + 'relaunchJob', + 'getEvents' + ]); + eventQueue = jasmine.createSpyObj('eventQueue', [ + 'populate', + 'markProcessed' + ]); + $compile = jasmine.createSpy('$compile'); + + $provide.value('jobData', jobData); + $provide.value('jobDataOptions', jobDataOptions); + $provide.value('jobLabels', jobLabels); + $provide.value('jobFinished', jobFinished); + $provide.value('count', count); + $provide.value('ParseTypeChange', ParseTypeChange); + $provide.value('ParseVariableString', ParseVariableString); + $provide.value('jobResultsService', jobResultsService); + $provide.value('eventQueue', eventQueue); + $provide.value('$compile', $compile); + }); + }; + + let injectVals = () => { + angular.mock.inject((_jobData_, _jobDataOptions_, _jobLabels_, _jobFinished_, _count_, _ParseTypeChange_, _ParseVariableString_, _jobResultsService_, _eventQueue_, _$compile_, $rootScope, $controller, $q, $httpBackend) => { + // when you call $scope.$apply() (which you need to do to + // to get inside of .then blocks to test), something is + // causing a request for all static files. + // + // this is a hack to just pass those requests through + // + // from googling this is probably due to angular-router + // weirdness + $httpBackend.when("GET", (url) => (url + .indexOf("/static/") !== -1)) + .respond(''); + + $scope = $rootScope.$new(); + $rScope = $rootScope; + q = $q; + jobData = _jobData_; + jobDataOptions = _jobDataOptions_; + jobLabels = _jobLabels_; + jobFinished = _jobFinished_; + count = _count_; + ParseTypeChange = _ParseTypeChange_; + ParseVariableString = _ParseVariableString_; + ParseVariableString.and.returnValue(jobData.extra_vars); + jobResultsService = _jobResultsService_; + eventQueue = _eventQueue_; + + jobResultsService.getEvents.and + .returnValue($q.when(eventResolve)); + eventQueue.populate.and + .returnValue($q.when(populateResolve)); + + $compile = _$compile_; + + jobResultsController = $controller('jobResultsController', { + $scope: $scope, + jobData: jobData, + jobDataOptions: jobDataOptions, + jobLabels: jobLabels, + jobFinished: jobFinished, + count: count, + ParseTypeChange: ParseTypeChange, + jobResultsService: jobResultsService, + eventQueue: eventQueue, + $compile: $compile + }); + }); + }; + + beforeEach(angular.mock.module('Tower')); + + let bootstrapTest = () => { + provideVals(); + injectVals(); + }; + + describe('bootstrap resolve values on scope', () => { + beforeEach(() => { + bootstrapTest(); + }); + + it('should set values to scope based on resolve', () => { + expect($scope.job).toBe(jobData); + expect($scope.jobOptions).toBe(jobDataOptions.actions.GET); + expect($scope.labels).toBe(jobLabels); + }); + }); + + describe('getTowerLinks()', () => { + beforeEach(() => { + jobData.related = { + "job_template": "api/v1/job_templates/12", + "created_by": "api/v1/users/12", + "inventory": "api/v1/inventories/12", + "project": "api/v1/projects/12", + "credential": "api/v1/credentials/12", + "cloud_credential": "api/v1/credentials/13", + "network_credential": "api/v1/credentials/14", + }; + + bootstrapTest(); + }); + + it('should transform related links and set to scope var', () => { + expect($scope.job_template_link).toBe('/#/job_templates/12'); + expect($scope.created_by_link).toBe('/#/users/12'); + expect($scope.inventory_link).toBe('/#/inventories/12'); + expect($scope.project_link).toBe('/#/projects/12'); + expect($scope.machine_credential_link).toBe('/#/credentials/12'); + expect($scope.cloud_credential_link).toBe('/#/credentials/13'); + expect($scope.network_credential_link).toBe('/#/credentials/14'); + }); + }); + + describe('getTowerLabels()', () => { + beforeEach(() => { + jobDataOptions.actions.GET = { + status: { + choices: [ + ["new", + "New"] + ] + }, + job_type: { + choices: [ + ["job", + "Playbook Run"] + ] + }, + verbosity: { + choices: [ + [0, + "0 (Normal)"] + ] + } + }; + jobData.status = "new"; + jobData.job_type = "job"; + jobData.verbosity = 0; + + bootstrapTest(); + }); + + it('should set scope variables based on options', () => { + expect($scope.status_label).toBe("New"); + expect($scope.type_label).toBe("Playbook Run"); + expect($scope.verbosity_label).toBe("0 (Normal)"); + }); + }); + + describe('extra vars stuff', () => { + let extraVars = "foo"; + + beforeEach(() => { + jobData.extra_vars = extraVars; + + bootstrapTest(); + }); + + it('should have extra vars on scope', () => { + expect($scope.job.extra_vars).toBe(extraVars); + }); + + it('should call ParseVariableString and set to scope', () => { + expect(ParseVariableString) + .toHaveBeenCalledWith(extraVars); + expect($scope.variables).toBe(extraVars); + }); + + it('should set the parse type to yaml', () => { + expect($scope.parseType).toBe('yaml'); + }); + + it('should call ParseTypeChange with proper params', () => { + let params = { + scope: $scope, + field_id: 'pre-formatted-variables', + readOnly: true + }; + + expect(ParseTypeChange) + .toHaveBeenCalledWith(params); + }); + }); + + describe('$scope.toggleStdoutFullscreen', () => { + beforeEach(() => { + bootstrapTest(); + }); + + it('should toggle $scope.stdoutFullScreen', () => { + // essentially set to false + expect($scope.stdoutFullScreen).toBe(false); + + // toggle once to true + $scope.toggleStdoutFullscreen(); + expect($scope.stdoutFullScreen).toBe(true); + + // toggle again to false + $scope.toggleStdoutFullscreen(); + expect($scope.stdoutFullScreen).toBe(false); + }); + }); + + describe('$scope.deleteJob', () => { + beforeEach(() => { + bootstrapTest(); + }); + + it('should delete the job', () => { + let job = $scope.job; + $scope.deleteJob(); + expect(jobResultsService.deleteJob).toHaveBeenCalledWith(job); + }); + }); + + describe('$scope.cancelJob', () => { + beforeEach(() => { + bootstrapTest(); + }); + + it('should cancel the job', () => { + let job = $scope.job; + $scope.cancelJob(); + expect(jobResultsService.cancelJob).toHaveBeenCalledWith(job); + }); + }); + + describe('$scope.relaunchJob', () => { + beforeEach(() => { + bootstrapTest(); + }); + + it('should relaunch the job', () => { + let scope = $scope; + $scope.relaunchJob(); + expect(jobResultsService.relaunchJob) + .toHaveBeenCalledWith(scope); + }); + }); + + describe('count stuff', () => { + beforeEach(() => { + count = { + val: { + ok: 1, + skipped: 2, + unreachable: 3, + failures: 4, + changed: 5 + }, + countFinished: true + }; + + bootstrapTest(); + }); + + it('should set count values to scope', () => { + expect($scope.count).toBe(count.val); + expect($scope.countFinished).toBe(true); + }); + + it('should find the hostCount based on the count', () => { + expect($scope.hostCount).toBe(15); + }); + }); + + describe('follow stuff - incomplete', () => { + beforeEach(() => { + jobFinished = false; + + bootstrapTest(); + }); + + it('should set followEngaged based on jobFinished incomplete', () => { + expect($scope.followEngaged).toBe(true); + }); + + it('should set followTooltip based on jobFinished incomplete', () => { + expect($scope.followTooltip).toBe("Currently following standard out as it comes in. Click to unfollow."); + }); + }); + + describe('follow stuff - finished', () => { + beforeEach(() => { + jobFinished = true; + + bootstrapTest(); + }); + + it('should set followEngaged based on jobFinished', () => { + expect($scope.followEngaged).toBe(false); + }); + + it('should set followTooltip based on jobFinished', () => { + expect($scope.followTooltip).toBe("Jump to last line of standard out."); + }); + }); + + describe('event stuff', () => { + beforeEach(() => { + jobData.id = 1; + jobData.related.job_events = "url"; + + bootstrapTest(); + }); + + it('should make a rest call to get already completed events', () => { + expect(jobResultsService.getEvents).toHaveBeenCalledWith("url"); + }); + + it('should call processEvent when receiving message', () => { + let eventPayload = {"foo": "bar"}; + $rScope.$broadcast('ws-job_events-1', eventPayload); + expect(eventQueue.populate).toHaveBeenCalledWith(eventPayload); + }); + + it('should set the job status on scope when receiving message', () => { + let eventPayload = { + unified_job_id: 1, + status: 'finished' + }; + $rScope.$broadcast('ws-jobs', eventPayload); + expect($scope.job.status).toBe(eventPayload.status); + }); + }); + + describe('getEvents and populate stuff', () => { + describe('getEvents', () => { + let event1 = { + event: 'foo' + }; + + let event2 = { + event_name: 'bar' + }; + + let event1Processed = { + event_name: 'foo' + }; + + beforeEach(() => { + eventResolve = { + results: [ + event1, + event2 + ] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('should change the event name to event_name', () => { + expect(eventQueue.populate) + .toHaveBeenCalledWith(event1Processed); + }); + + it('should pass through the event with event_name', () => { + expect(eventQueue.populate) + .toHaveBeenCalledWith(event2); + }); + + it('should have called populate twice', () => { + expect(eventQueue.populate.calls.count()).toEqual(2); + }); + + // TODO: can't figure out how to a test of events.next... + // if you set events.next to true it causes the tests to + // stop running + }); + + describe('populate - start time', () => { + beforeEach(() => { + jobData.start = ""; + + populateResolve = { + startTime: 'foo', + changes: ['startTime'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('sets start time when passed as a change', () => { + expect($scope.job.start).toBe('foo'); + }); + }); + + describe('populate - start time already set', () => { + beforeEach(() => { + jobData.start = "bar"; + + populateResolve = { + startTime: 'foo', + changes: ['startTime'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('does not set start time because already set', () => { + expect($scope.job.start).toBe('bar'); + }); + }); + + describe('populate - count already received', () => { + let receiveCount = { + ok: 2, + skipped: 2, + unreachable: 2, + failures: 2, + changed: 2 + }; + + let alreadyCount = { + ok: 3, + skipped: 3, + unreachable: 3, + failures: 3, + changed: 3 + }; + + beforeEach(() => { + count.countFinished = true; + count.val = alreadyCount; + + populateResolve = { + count: receiveCount, + changes: ['count'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('count does not change', () => { + expect($scope.count).toBe(alreadyCount); + expect($scope.hostCount).toBe(15); + }); + }); + + describe('populate - playCount, taskCount and countFinished', () => { + beforeEach(() => { + + populateResolve = { + playCount: 12, + taskCount: 13, + changes: ['playCount', 'taskCount', 'countFinished'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('sets playCount', () => { + expect($scope.playCount).toBe(12); + }); + + it('sets taskCount', () => { + expect($scope.taskCount).toBe(13); + }); + + it('sets countFinished', () => { + expect($scope.countFinished).toBe(true); + }); + }); + + describe('populate - finishedTime', () => { + beforeEach(() => { + jobData.finished = ""; + + populateResolve = { + finishedTime: "finished_time", + changes: ['finishedTime'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('sets finished time and changes follow tooltip', () => { + expect($scope.job.finished).toBe('finished_time'); + expect($scope.jobFinished).toBe(true); + expect($scope.followTooltip) + .toBe("Jump to last line of standard out."); + }); + }); + + describe('populate - finishedTime when already finished', () => { + beforeEach(() => { + jobData.finished = "already_set"; + + populateResolve = { + finishedTime: "finished_time", + changes: ['finishedTime'] + }; + + bootstrapTest(); + + $scope.$apply(); + }); + + it('does not set finished time because already set', () => { + expect($scope.job.finished).toBe('already_set'); + expect($scope.jobFinished).toBe(true); + expect($scope.followTooltip) + .toBe("Jump to last line of standard out."); + }); + }); + + // TODO: stdout change tests + }); +}); diff --git a/awx/ui/tests/spec/job-results/parse-stdout.service-test.js b/awx/ui/tests/spec/job-results/parse-stdout.service-test.js new file mode 100644 index 0000000000..23fbae2a01 --- /dev/null +++ b/awx/ui/tests/spec/job-results/parse-stdout.service-test.js @@ -0,0 +1,173 @@ +'use strict'; + +describe('parseStdoutService', () => { + let parseStdoutService, + log; + + beforeEach(angular.mock.module('Tower')); + + beforeEach(angular.mock.module('jobResults',($provide) => { + log = jasmine.createSpyObj('$log', [ + 'error' + ]); + + $provide.value('$log', log); + })); + + beforeEach(angular.mock.inject((_$log_, _parseStdoutService_) => { + parseStdoutService = _parseStdoutService_; + })); + + describe('prettify()', () => { + it('returns lines of stdout with styling classes', () => { + let line = "[0;32mok: [host-00]", + styledLine = 'ok: [host-00]'; + expect(parseStdoutService.prettify(line).toBe(styledLine)); + }); + + it('can return lines of stdout without styling classes', () => { + let line = "[0;32mok: [host-00]", + unstyled = "unstyled", + unstyledLine = 'ok: [host-00]'; + expect(parseStdoutService.prettify(line, unstyled).toBe(unstyledLine)); + }); + }); + + describe('getLineClasses()', () => { + it('creates a string that is used as a class', () => { + let headerEvent = { + event_name: 'playbook_on_task_start', + event_data: { + task_uuid: '1da9012d-18e6-4562-85cd-83cf10a97f86' + } + }; + let lineNum = 3; + let line = "TASK [setup] *******************************************************************"; + let styledLine = "header_task header_task_80dd087c-268b-45e8-9aab-1083bcfd9364 play_0f667a23-d9ab-4128-a735-80566bcdbca0 line_num_3"; + expect(parseStdoutService.getLineClasses(headerEvent, line, lineNum).toBe(styledLine)); + }); + + + }); + + describe('getCollapseIcon()', () => { + let emptySpan = ` +`; + + it('returns empty expander for non-header event', () => { + let nonHeaderEvent = { + event_name: 'not_header', + start_line: 0, + end_line: 1, + stdout:"line1" + }; + expect(parseStdoutService.getCollapseIcon(nonHeaderEvent)) + .toBe(emptySpan); + }); + + it('returns collapse/decollapse icons for header events', () => { + let headerEvent = { + event_name: 'playbook_on_task_start', + start_line: 0, + end_line: 1, + stdout:"line1", + event_data: { + task_uuid: '1da9012d-18e6-4562-85cd-83cf10a97f86' + } + }; + let line = "TASK [setup] *******************************************************************"; + let expandSpan = ` + + + +`; + expect(parseStdoutService.getCollapseIcon(headerEvent, line)) + .toBe(expandSpan); + }); + }); + + describe('getLineArr()', () => { + it('returns stdout in array format', () => { + let mockEvent = { + start_line: 12, + end_line: 14, + stdout: "line1\r\nline2\r\n" + }; + let expectedReturn = [[13, "line1"],[14, "line2"]]; + + let returnedEvent = parseStdoutService.getLineArr(mockEvent); + + expect(returnedEvent).toEqual(expectedReturn); + }); + }); + + describe('parseStdout()', () => { + let mockEvent = {"foo": "bar"}; + + it('calls functions', function() { + spyOn(parseStdoutService, 'getLineArr').and + .returnValue([[13, 'line1'], [14, 'line2']]); + spyOn(parseStdoutService, 'getLineClasses').and + .returnValue(""); + spyOn(parseStdoutService, 'getCollapseIcon').and + .returnValue(""); + spyOn(parseStdoutService, 'getAnchorTags').and + .returnValue(""); + spyOn(parseStdoutService, 'prettify').and + .returnValue("prettified_line"); + + parseStdoutService.parseStdout(mockEvent); + + expect(parseStdoutService.getLineArr) + .toHaveBeenCalledWith(mockEvent); + expect(parseStdoutService.getLineClasses) + .toHaveBeenCalledWith(mockEvent, 'line1', 13); + expect(parseStdoutService.getCollapseIcon) + .toHaveBeenCalledWith(mockEvent, 'line1'); + expect(parseStdoutService.getAnchorTags) + .toHaveBeenCalledWith(mockEvent, "prettified_line"); + expect(parseStdoutService.prettify) + .toHaveBeenCalledWith('line1'); + + // get line arr should be called once for the event + expect(parseStdoutService.getLineArr.calls.count()) + .toBe(1); + + // other functions should be called twice (once for each + // line) + expect(parseStdoutService.getLineClasses.calls.count()) + .toBe(2); + expect(parseStdoutService.getCollapseIcon.calls.count()) + .toBe(2); + expect(parseStdoutService.getAnchorTags.calls.count()) + .toBe(2); + expect(parseStdoutService.prettify.calls.count()) + .toBe(2); + }); + + it('returns dom-ified lines', function() { + spyOn(parseStdoutService, 'getLineArr').and + .returnValue([[13, 'line1']]); + spyOn(parseStdoutService, 'getLineClasses').and + .returnValue("line_classes"); + spyOn(parseStdoutService, 'getCollapseIcon').and + .returnValue("collapse_icon_dom"); + spyOn(parseStdoutService, 'getAnchorTags').and + .returnValue("anchor_tag_dom"); + spyOn(parseStdoutService, 'prettify').and + .returnValue("prettified_line"); + + var returnedString = parseStdoutService.parseStdout(mockEvent); + + var expectedString = ` +
+
collapse_icon_dom13
+
anchor_tag_dom
+
`; + expect(returnedString).toBe(expectedString); + }); + }); +}); diff --git a/awx/ui/webpack.config.js b/awx/ui/webpack.config.js index 067ba50dca..6c8a7b1ffe 100644 --- a/awx/ui/webpack.config.js +++ b/awx/ui/webpack.config.js @@ -18,6 +18,7 @@ var vendorPkgs = [ 'angular-codemirror', 'angular-cookies', 'angular-drag-and-drop-lists', + 'angular-duration-format', 'angular-gettext', 'angular-md5', 'angular-moment',