+ CREATED
+ {{event.created || "No result found"}}
+
+
+ PLAY
+ {{event.play || "No result found"}}
+
+
+ TASK
+ {{event.task || "No result found"}}
+
+
+ MODULE
+ {{event.event_data.res.invocation.module_name || "No result found"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+