diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 7915a1fead..6036fba80f 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -154,7 +154,7 @@ STDOUT_MAX_BYTES_DISPLAY = 1048576 # Returned in the header on event api lists as a recommendation to the UI # on how many events to display before truncating/hiding -RECOMMENDED_MAX_EVENTS_DISPLAY_HEADER = 10000 +RECOMMENDED_MAX_EVENTS_DISPLAY_HEADER = 4000 # The maximum size of the ansible callback event's res data structure # beyond this limit and the value will be removed 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 index f7fbd2e8f6..778d238e47 100644 --- 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 @@ -15,7 +15,7 @@ export default [ 'templateUrl', link: function(scope) { // as count is changed by event data coming in, // update the host status bar - scope.$watch('count', function(val) { + var toDestroy = scope.$watch('count', function(val) { if (val) { Object.keys(val).forEach(key => { // reposition the hosts status bar by setting @@ -38,6 +38,10 @@ export default [ 'templateUrl', .filter(key => (val[key] > 0)).length > 0); } }); + + scope.$on('$destroy', function(){ + toDestroy(); + }); } }; }]; 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 index 408e4cbb32..7ff93d17ee 100644 --- 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 @@ -162,6 +162,7 @@ .JobResultsStdOut-stdoutColumn { padding-left: 20px; + padding-right: 20px; padding-top: 2px; padding-bottom: 2px; color: @default-interface-txt; @@ -171,6 +172,12 @@ width:100%; } +.JobResultsStdOut-stdoutColumn--tooMany { + font-weight: bold; + text-transform: uppercase; + color: @default-err; +} + .JobResultsStdOut-stdoutColumn { cursor: pointer; } @@ -216,6 +223,11 @@ color: @default-interface-txt; } +.JobResultsStdOut-cappedLine { + color: @b7grey; + font-style: italic; +} + @media (max-width: @breakpoint-md) { .JobResultsStdOut-numberColumnPreload { display: none; 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 index 77c87c74da..14a34a607a 100644 --- 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 @@ -12,6 +12,18 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', templateUrl: templateUrl('job-results/job-results-stdout/job-results-stdout'), restrict: 'E', link: function(scope, element) { + var toDestroy = [], + resizer, + scrollWatcher; + + scope.$on('$destroy', function(){ + $(window).off("resize", resizer); + $(window).off("scroll", scrollWatcher); + $(".JobResultsStdOut-stdoutContainer").off('scroll', + scrollWatcher); + toDestroy.forEach(closureFunc => closureFunc()); + }); + scope.stdoutContainerAvailable.resolve("container available"); // utility function used to find the top visible line and // parent header in the pane @@ -115,9 +127,15 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', // stop iterating over the standard out // lines once the first one has been // found + + $this = null; return false; - } - }); + } + + $this = null; + }); + + $container = null; return { visLine: visItem, @@ -131,22 +149,24 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', } else { scope.isMobile = false; } - // watch changes to the window size - $(window).resize(function() { + + resizer = 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; } - }); + }; + // watch changes to the window size + $(window).resize(resizer); var lastScrollTop; var initScrollTop = function() { lastScrollTop = 0; }; - var scrollWatcher = function() { + scrollWatcher = function() { var st = $(this).scrollTop(); var netScroll = st + $(this).innerHeight(); var fullHeight; @@ -178,11 +198,15 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', } lastScrollTop = st; + + st = null; + netScroll = null; + fullHeight = null; }; // update scroll watchers when isMobile changes based on // window resize - scope.$watch('isMobile', function(val) { + toDestroy.push(scope.$watch('isMobile', function(val) { if (val === true) { // make sure ^ TOP always shown for mobile scope.stdoutOverflowed = true; @@ -204,7 +228,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', $(".JobResultsStdOut-stdoutContainer").on('scroll', scrollWatcher); } - }); + })); // called to scroll to follow anchor scope.followScroll = function() { @@ -237,7 +261,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', // if following becomes active, go ahead and get to the bottom // of the standard out pane - scope.$watch('followEngaged', function(val) { + toDestroy.push(scope.$watch('followEngaged', function(val) { // scroll to follow point if followEngaged is true if (val) { scope.followScroll(); @@ -251,7 +275,7 @@ export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', scope.followTooltip = "Click to follow standard out as it comes in."; } } - }); + })); // follow button ng-click function scope.followToggleClicked = function() { 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 index 0ba992b146..63e41b8fa8 100644 --- 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 @@ -34,6 +34,13 @@
+
+
+ +
+
The standard output is too large to display. Please specify additional filters to narrow the standard out.
+
val[0] === status) .map(val => val[1])[0]; $scope.status_tooltip = "Job " + $scope.status_label; } - }); + })); // update the job_status value. Use the cached rootScope value if there // is one. This is a workaround when the rest call for the jobData is @@ -185,7 +191,12 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy // 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) { + var processEvent = function(event, context) { + // only care about filter context checking when the event comes + // as a rest call + if (context && context !== currentContext) { + return; + } // put the event in the queue var mungedEvent = eventQueue.populate(event); @@ -278,6 +289,9 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy .stdout)($scope.events[mungedEvent .counter])); } + + classList = null; + putIn = null; } else { // this is a header or recap line, so just // append to the bottom @@ -357,99 +371,113 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy getSkeleton(jobData.related.job_events + "?order_by=id&or__event__in=playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats"); }); + var getEvents; + + var processPage = function(events, context) { + // currentContext is the context of the filter when this request + // to processPage was made + // + // currentContext is the context of the filter currently + // + // if they are not the same, make sure to stop process events/ + // making rest calls for next pages/etc. (you can see context is + // also passed into getEvents and processEvent and similar checks + // exist in these functions) + // + // also, if the page doesn't contain results (i.e.: the response + // returns an error), don't process the page + if (context !== currentContext || events === undefined || + events.results === undefined) { + return; + } + + 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, context); + }); + if (events.next && !cancelRequests) { + getEvents(events.next, context); + } else { + // put those paused events into the pane + $scope.gotPreviouslyRanEvents.resolve(""); + } + }; + // grab non-header recap lines - var getEvents = function(url) { + getEvents = function(url, context) { + if (context !== currentContext) { + return; + } + 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); - } else { - // put those paused events into the pane - $scope.gotPreviouslyRanEvents.resolve(""); - } + processPage(events, context); }); }; // grab non-header recap lines - $scope.$watch('job_event_dataset', function(val) { + toDestroy.push($scope.$watch('job_event_dataset', function(val) { + eventQueue.initialize(); + + Object.keys($scope.events) + .forEach(v => { + // dont destroy scope events for skeleton lines + let name = $scope.events[v].event.name; + + if (!(name === "playbook_on_play_start" || + name === "playbook_on_task_start" || + name === "playbook_on_stats")) { + $scope.events[v].$destroy(); + $scope.events[v] = null; + delete $scope.events[v]; + } + }); + // pause websocket events from coming in to the pane $scope.gotPreviouslyRanEvents = $q.defer(); + currentContext += 1; + + let context = currentContext; $( ".JobResultsStdOut-aLineOfStdOut.not_skeleton" ).remove(); $scope.hasSkeleton.promise.then(() => { - val.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 (val.next) { - getEvents(val.next); + if (val.count > parseInt(val.maxEvents)) { + $(".header_task").hide(); + $(".header_play").hide(); + $scope.tooManyEvents = true; } else { - // put those paused events into the pane - $scope.gotPreviouslyRanEvents.resolve(""); + $(".header_task").show(); + $(".header_play").show(); + $scope.tooManyEvents = false; + processPage(val, context); } }); - }); + })); // Processing of job_events messages from the websocket - $scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) { + toDestroy.push($scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) { $q.all([$scope.gotPreviouslyRanEvents.promise, $scope.hasSkeleton.promise]).then(() => { - var url = Dataset - .config.url.split("?")[0] + - QuerySet.encodeQueryset($state.params.job_event_search); - var noFilter = (url.split("&") - .filter(v => v.indexOf("page=") !== 0 && - v.indexOf("/api/v1") !== 0 && - v.indexOf("order_by=id") !== 0 && - v.indexOf("not__event__in=playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats") !== 0).length === 0); - - if(data.event_name === "playbook_on_start" || - data.event_name === "playbook_on_play_start" || - data.event_name === "playbook_on_task_start" || - data.event_name === "playbook_on_stats" || - noFilter) { - // for header and recap lines, as well as if no filters - // were added by the user, just put the line in the - // standard out pane (and increment play and task - // count) - if (data.event_name === "playbook_on_play_start") { - $scope.playCount++; - } else if (data.event_name === "playbook_on_task_start") { - $scope.taskCount++; - } - processEvent(data); - } else { - // to make sure host event/verbose lines go through a - // user defined filter, appent the id to the url, and - // make a request to the job_events endpoint with the - // id of the incoming event appended. If the event, - // is returned, put the line in the standard out pane - Rest.setUrl(`${url}&id=${data.id}`); - Rest.get() - .success(function(isHere) { - if (isHere.count) { - processEvent(data); - } - }); + // put the line in the + // standard out pane (and increment play and task + // count if applicable) + if (data.event_name === "playbook_on_play_start") { + $scope.playCount++; + } else if (data.event_name === "playbook_on_task_start") { + $scope.taskCount++; } - + processEvent(data); }); - }); + })); // Processing of job-status messages from the websocket - $scope.$on(`ws-jobs`, function(e, data) { + toDestroy.push($scope.$on(`ws-jobs`, function(e, data) { if (parseInt(data.unified_job_id, 10) === parseInt($scope.job.id,10)) { // controller is defined, so set the job_status @@ -477,5 +505,19 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy // for this job. cache the socket status on root scope $rootScope['lastSocketStatus' + data.unified_job_id] = data.status; } + })); + + $scope.$on('$destroy', function(){ + $( ".JobResultsStdOut-aLineOfStdOut" ).remove(); + cancelRequests = true; + eventQueue.initialize(); + Object.keys($scope.events) + .forEach(v => { + $scope.events[v].$destroy(); + $scope.events[v] = null; + }); + $scope.events = {}; + clearInterval(elapsedInterval); + toDestroy.forEach(closureFunc => closureFunc()); }); }]; diff --git a/awx/ui/client/src/job-results/job-results.route.js b/awx/ui/client/src/job-results/job-results.route.js index 88154d3114..ec47cc7757 100644 --- a/awx/ui/client/src/job-results/job-results.route.js +++ b/awx/ui/client/src/job-results/job-results.route.js @@ -25,6 +25,7 @@ export default { params: { job_event_search: { value: { + page_size: 100, order_by: 'id', not__event__in: 'playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats' }, diff --git a/awx/ui/client/src/job-results/parse-stdout.service.js b/awx/ui/client/src/job-results/parse-stdout.service.js index 8d6564f3f0..f04b5d1fb6 100644 --- a/awx/ui/client/src/job-results/parse-stdout.service.js +++ b/awx/ui/client/src/job-results/parse-stdout.service.js @@ -27,6 +27,7 @@ export default ['$log', 'moment', function($log, moment){ line = line.replace(/u001b/g, ''); // ansi classes + line = line.replace(/\[1;im/g, ''); line = line.replace(/\[1;31m/g, ''); line = line.replace(/\[0;31m/g, ''); line = line.replace(/\[0;32m/g, ''); @@ -185,7 +186,6 @@ export default ['$log', 'moment', function($log, moment){ data-uuid="${clickClass}"> `; - // console.log(expandDom); return expandDom; } else { // non-header lines don't get an expander @@ -193,10 +193,22 @@ export default ['$log', 'moment', function($log, moment){ } }, getLineArr: function(event) { - return _ - .zip(_.range(event.start_line + 1, - event.end_line + 1), - event.stdout.replace("\t", " ").split("\r\n").slice(0, -1)); + let lineNums = _.range(event.start_line + 1, + event.end_line + 1); + + let lines = event.stdout + .replace("\t", " ") + .split("\r\n"); + + if (lineNums.length > lines.length) { + let padBy = lineNums.length - lines.length; + + for (let i = 0; i <= padBy; i++) { + lines.push("[1;imLine capped.[0m"); + } + } + + return _.zip(lineNums, lines).slice(0, -1); }, // public function that provides the parsed stdout line, given a // job_event diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 86a2bc2b20..c759e86669 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -238,21 +238,33 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear Wait('start'); this.url = `${endpoint}${this.encodeQueryset(params)}`; Rest.setUrl(this.url); + return Rest.get() - .success(this.success.bind(this)) - .error(this.error.bind(this)) - .finally(Wait('stop')); + .then(function(response) { + Wait('stop'); + + if (response + .headers('X-UI-Max-Events') !== null) { + response.data.maxEvents = response. + headers('X-UI-Max-Events'); + } + + return response; + }) + .catch(function(response) { + Wait('stop'); + + this.error(response.data, response.status); + + return response; + }.bind(this)); }, error(data, status) { ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', msg: 'Call to ' + this.url + '. GET returned: ' + status }); - }, - success(data) { - return data; - }, - + } }; } ]; 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 index 5dd6788b02..c4aeace3e7 100644 --- a/awx/ui/tests/spec/job-results/parse-stdout.service-test.js +++ b/awx/ui/tests/spec/job-results/parse-stdout.service-test.js @@ -31,10 +31,14 @@ describe('parseStdoutService', () => { unstyledLine = 'ok: [host-00]'; expect(parseStdoutService.prettify(line, unstyled)).toBe(unstyledLine); }); + + it('can return empty strings', () => { + expect(parseStdoutService.prettify("")).toBe(""); + }); }); describe('getLineClasses()', () => { - xit('creates a string that is used as a class', () => { + it('creates a string that is used as a class', () => { let headerEvent = { event_name: 'playbook_on_task_start', event_data: { @@ -44,12 +48,15 @@ describe('parseStdoutService', () => { }; 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"; + let styledLine = " header_task header_task_80dd087c-268b-45e8-9aab-1083bcfd9364 actual_header play_0f667a23-d9ab-4128-a735-80566bcdbca0 line_num_3"; expect(parseStdoutService.getLineClasses(headerEvent, line, lineNum)).toBe(styledLine); }); }); describe('getStartTime()', () => { + // TODO: the problem is that the date here calls moment, and thus + // the date will be timezone'd in the string (this could be + // different based on where you are) xit('creates returns a badge with the start time of the event', () => { let headerEvent = { event_name: 'playbook_on_play_start', @@ -115,6 +122,19 @@ describe('parseStdoutService', () => { expect(returnedEvent).toEqual(expectedReturn); }); + + it('deals correctly with capped lines', () => { + let mockEvent = { + start_line: 7, + end_line: 11, + stdout: "a\r\nb\r\nc..." + }; + let expectedReturn = [[8, "a"],[9, "b"], [10,"c..."], [11, "[1;imLine capped.[0m"]]; + + let returnedEvent = parseStdoutService.getLineArr(mockEvent); + + expect(returnedEvent).toEqual(expectedReturn); + }); }); describe('parseStdout()', () => {