diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js
index ce8a047e7f..25ebb9b531 100644
--- a/awx/ui/client/features/output/index.controller.js
+++ b/awx/ui/client/features/output/index.controller.js
@@ -1,83 +1,41 @@
-import Ansi from 'ansi-to-html';
-import hasAnsi from 'has-ansi';
-
-let vm;
-let ansi;
-let model;
-let resource;
-let page;
-let container;
-let $timeout;
-let $sce;
-let $compile;
-let $scope;
-let $q;
-
-const record = {};
-
-let parent = null;
-
-const SCROLL_THRESHOLD = 0.1;
-const SCROLL_DELAY = 1000;
-const EVENT_START_TASK = 'playbook_on_task_start';
-const EVENT_START_PLAY = 'playbook_on_play_start';
-const EVENT_STATS_PLAY = 'playbook_on_stats';
-const ELEMENT_TBODY = '#atStdoutResultTable';
-const ELEMENT_CONTAINER = '.at-Stdout-container';
const JOB_START = 'playbook_on_start';
const JOB_END = 'playbook_on_stats';
-const EVENT_GROUPS = [
- EVENT_START_TASK,
- EVENT_START_PLAY
-];
-
-const TIME_EVENTS = [
- EVENT_START_TASK,
- EVENT_START_PLAY,
- EVENT_STATS_PLAY
-];
+let vm;
+let $compile;
+let $scope;
+let $q;
+let page;
+let render;
+let scroll;
+let resource;
function JobsIndexController (
_resource_,
_page_,
- _$sce_,
- _$timeout_,
+ _scroll_,
+ _render_,
_$scope_,
_$compile_,
_$q_
) {
vm = this || {};
- $timeout = _$timeout_;
- $sce = _$sce_;
$compile = _$compile_;
$scope = _$scope_;
$q = _$q_;
resource = _resource_;
+
page = _page_;
- model = resource.model;
-
- ansi = new Ansi();
-
- const events = model.get(`related.${resource.related}.results`);
- const parsed = parseEvents(events);
- const html = $sce.trustAsHtml(parsed.html);
-
- page.init(resource);
-
- page.add({ number: 1, lines: parsed.lines });
+ scroll = _scroll_;
+ render = _render_;
// Development helper(s)
vm.clear = devClear;
// Stdout Navigation
vm.scroll = {
- isLocked: false,
showBackToTop: false,
- isActive: false,
- position: 0,
- time: 0,
home: scrollHome,
end: scrollEnd,
down: scrollPageDown,
@@ -90,87 +48,93 @@ function JobsIndexController (
vm.isExpanded = true;
// Real-time (active between JOB_START and JOB_END events only)
- $scope.$on(resource.ws.namespace, processWebSocketEvents);
vm.stream = {
- isActive: false,
- isRendering: false,
- isPaused: false,
- buffered: 0,
- count: 0,
- page: 1
+ active: false,
+ rendering: false,
+ paused: false
};
- window.requestAnimationFrame(() => {
- const table = $(ELEMENT_TBODY);
- container = $(ELEMENT_CONTAINER);
+ const stream = false; // TODO: Set in route
- table.html($sce.getTrustedHtml(html));
- $compile(table.contents())($scope);
-
- container.scroll(onScroll);
- });
+ render.requestAnimationFrame(() => init());
}
-function processWebSocketEvents (scope, data) {
- let done;
+function init (stream) {
+ page.init(resource);
+ render.init({
+ get: () => resource.model.get(`related.${resource.related}.results`),
+ compile: html => $compile(html)($scope)
+ });
+
+ scroll.init({
+ isAtRest: scrollIsAtRest,
+ previous,
+ next
+ });
+
+ if (stream) {
+ $scope.$on(resource.ws.namespace, process);
+ } else {
+ next();
+ }
+}
+
+function process (scope, data) {
if (data.event === JOB_START) {
- vm.scroll.isActive = true;
- vm.stream.isActive = true;
- vm.scroll.isLocked = true;
+ vm.stream.active = true;
+ scroll.lock();
} else if (data.event === JOB_END) {
- vm.stream.isActive = false;
+ vm.stream.active = false;
}
const pageAdded = page.addToBuffer(data);
- if (pageAdded && !vm.scroll.isLocked) {
- vm.stream.isPaused = true;
+ if (pageAdded && !scroll.isLocked()) {
+ vm.stream.paused = true;
}
- if (vm.stream.isPaused && vm.scroll.isLocked) {
- vm.stream.isPaused = false;
+ if (vm.stream.paused && scroll.isLocked()) {
+ vm.stream.paused = false;
}
- if (vm.stream.isRendering || vm.stream.isPaused) {
+ if (vm.stream.rendering || vm.stream.paused) {
return;
}
const events = page.emptyBuffer();
- return render(events);
+ return renderStream(events);
}
-function render (events) {
- vm.stream.isRendering = true;
+function renderStream (events) {
+ vm.stream.rendering = true;
return shift()
.then(() => append(events))
.then(() => {
- if (vm.scroll.isLocked) {
- const height = container[0].scrollHeight;
- container[0].scrollTop = height;
+ if (scroll.isLocked()) {
+ scroll.setScrollPosition(scroll.getScrollHeight());
}
- if (!vm.stream.isActive) {
+ if (!vm.stream.active) {
const buffer = page.emptyBuffer();
if (buffer.length) {
- return render(buffer);
+ return renderStream(buffer);
+ } else {
+ vm.stream.rendering = false;
+ scroll.unlock();
}
-
- vm.stream.isRendering = false;
- vm.scroll.isLocked = false;
- vm.scroll.isActive = false;
} else {
- vm.stream.isRendering = false;
+ vm.stream.rendering = false;
}
});
}
function devClear () {
- page.init(resource);
- clear();
+ init(true);
+ render.clear();
}
function next () {
@@ -182,13 +146,12 @@ function next () {
return shift()
.then(() => append(events));
- })
+ });
}
function previous () {
- const container = $(ELEMENT_CONTAINER)[0];
-
- let previousHeight;
+ let initialPosition = scroll.getScrollPosition();
+ let postPopHeight;
return page.previous()
.then(events => {
@@ -198,296 +161,56 @@ function previous () {
return pop()
.then(() => {
- previousHeight = container.scrollHeight;
+ postPopHeight = scroll.getScrollHeight();
return prepend(events);
})
.then(() => {
- const currentHeight = container.scrollHeight;
- container.scrollTop = currentHeight - previousHeight;
+ const currentHeight = scroll.getScrollHeight();
+
+ scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition);
});
});
}
function append (events) {
- return $q(resolve => {
- window.requestAnimationFrame(() => {
- const parsed = parseEvents(events);
- const rows = $($sce.getTrustedHtml($sce.trustAsHtml(parsed.html)));
- const table = $(ELEMENT_TBODY);
-
- page.updateLineCount('current', parsed.lines);
-
- table.append(rows);
- $compile(rows.contents())($scope);
-
- return resolve();
+ return render.append(events)
+ .then(count => {
+ page.updateLineCount('current', count);
});
- });
}
function prepend (events) {
- return $q(resolve => {
- window.requestAnimationFrame(() => {
- const parsed = parseEvents(events);
- const rows = $($sce.getTrustedHtml($sce.trustAsHtml(parsed.html)));
- const table = $(ELEMENT_TBODY);
-
- page.updateLineCount('current', parsed.lines);
-
- table.prepend(rows);
- $compile(rows.contents())($scope);
-
- $scope.$apply(() => {
- return resolve(parsed.lines);
- });
+ return render.prepend(events)
+ .then(count => {
+ page.updateLineCount('current', count);
});
- });
}
function pop () {
- return $q(resolve => {
- if (!page.isOverCapacity()) {
- return resolve();
- }
+ if (!page.isOverCapacity()) {
+ return $q.resolve();
+ }
- window.requestAnimationFrame(() => {
- const lines = page.trim('right');
- const rows = $(ELEMENT_TBODY).children().slice(-lines);
+ const lines = page.trim('right');
- rows.empty();
- rows.remove();
-
- return resolve();
- });
- });
+ return render.pop(lines);
}
function shift () {
- return $q(resolve => {
- if (!page.isOverCapacity()) {
- return resolve();
- }
+ if (!page.isOverCapacity()) {
+ return $q.resolve();
+ }
- window.requestAnimationFrame(() => {
- const lines = page.trim('left');
- const rows = $(ELEMENT_TBODY).children().slice(0, lines);
+ const lines = page.trim('left');
- rows.empty();
- rows.remove();
-
- return resolve();
- });
- });
-}
-
-function clear () {
- return $q(resolve => {
- window.requestAnimationFrame(() => {
- const rows = $(ELEMENT_TBODY).children();
-
- rows.empty();
- rows.remove();
-
- return resolve();
- });
- });
+ return render.shift(lines);
}
function expand () {
vm.toggle(parent, true);
}
-function parseEvents (events) {
- let lines = 0;
- let html = '';
-
- events.sort(orderByLineNumber);
-
- events.forEach(event => {
- const line = parseLine(event);
-
- html += line.html;
- lines += line.count;
- });
-
- return {
- html,
- lines
- };
-}
-
-function orderByLineNumber (a, b) {
- if (a.start_line > b.start_line) {
- return 1;
- }
-
- if (a.start_line < b.start_line) {
- return -1;
- }
-
- return 0;
-}
-
-function parseLine (event) {
- if (!event || !event.stdout) {
- return { html: '', count: 0 };
- }
-
- const { stdout } = event;
- const lines = stdout.split('\r\n');
-
- let count = lines.length;
- let ln = event.start_line;
-
- const current = createRecord(ln, lines, event);
-
- const html = lines.reduce((html, line, i) => {
- ln++;
-
- const isLastLine = i === lines.length - 1;
- let row = createRow(current, ln, line);
-
- if (current && current.isTruncated && isLastLine) {
- row += createRow(current);
- count++;
- }
-
- return `${html}${row}`;
- }, '');
-
- return { html, count };
-}
-
-function createRecord (ln, lines, event) {
- if (!event.uuid) {
- return null;
- }
-
- const info = {
- id: event.id,
- line: ln + 1,
- uuid: event.uuid,
- level: event.event_level,
- start: event.start_line,
- end: event.end_line,
- isTruncated: (event.end_line - event.start_line) > lines.length,
- isHost: typeof event.host === 'number'
- };
-
- if (event.parent_uuid) {
- info.parents = getParentEvents(event.parent_uuid);
- }
-
- if (info.isTruncated) {
- info.truncatedAt = event.start_line + lines.length;
- }
-
- if (EVENT_GROUPS.includes(event.event)) {
- info.isParent = true;
-
- if (event.event_level === 1) {
- parent = event.uuid;
- }
-
- if (event.parent_uuid) {
- if (record[event.parent_uuid]) {
- if (record[event.parent_uuid].children &&
- !record[event.parent_uuid].children.includes(event.uuid)) {
- record[event.parent_uuid].children.push(event.uuid);
- } else {
- record[event.parent_uuid].children = [event.uuid];
- }
- }
- }
- }
-
- if (TIME_EVENTS.includes(event.event)) {
- info.time = getTime(event.created);
- info.line++;
- }
-
- record[event.uuid] = info;
-
- return info;
-}
-
-function getParentEvents (uuid, list) {
- list = list || [];
-
- if (record[uuid]) {
- list.push(uuid);
-
- if (record[uuid].parents) {
- list = list.concat(record[uuid].parents);
- }
- }
-
- return list;
-}
-
-function createRow (current, ln, content) {
- let id = '';
- let timestamp = '';
- let tdToggle = '';
- let tdEvent = '';
- let classList = '';
-
- content = content || '';
-
- if (hasAnsi(content)) {
- content = ansi.toHtml(content);
- }
-
- if (current) {
- if (current.isParent && current.line === ln) {
- id = current.uuid;
- tdToggle = `
| `;
- }
-
- if (current.isHost) {
- tdEvent = `${content} | `;
- }
-
- if (current.time && current.line === ln) {
- timestamp = `${current.time}`;
- }
-
- if (current.parents) {
- classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
- }
- }
-
- if (!tdEvent) {
- tdEvent = `${content} | `;
- }
-
- if (!tdToggle) {
- tdToggle = ' | ';
- }
-
- if (!ln) {
- ln = '...';
- }
-
- return `
-
- ${tdToggle}
- | ${ln} |
- ${tdEvent}
- ${timestamp} |
-
`;
-}
-
-function getTime (created) {
- const date = new Date(created);
- const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours();
- const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes();
- const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds();
-
- return `${hour}:${minute}:${second}`;
-}
-
function showHostDetails (id) {
jobEvent.request('get', id)
.then(() => {
@@ -527,100 +250,38 @@ function toggle (uuid, menu) {
}
}
-function onScroll () {
- if (vm.scroll.isActive) {
- return;
- }
-
- if (vm.scroll.register) {
- $timeout.cancel(vm.scroll.register);
- }
-
- vm.scroll.register = $timeout(registerScrollEvent, SCROLL_DELAY);
-}
-
-function registerScrollEvent () {
- vm.scroll.isActive = true;
-
- const position = container[0].scrollTop;
- const height = container[0].offsetHeight;
- const downward = position > vm.scroll.position;
-
- let promise;
-
- if (position !== 0 ) {
- vm.scroll.showBackToTop = true;
- } else {
- vm.scroll.showBackToTop = false;
- }
-
-
- console.log('downward', downward);
- if (downward) {
- if (((height - position) / height) < SCROLL_THRESHOLD) {
- promise = next;
- }
- } else {
- if ((position / height) < SCROLL_THRESHOLD) {
- console.log('previous');
- promise = previous;
- }
- }
-
- vm.scroll.position = position;
-
- if (!promise) {
- vm.scroll.isActive = false;
-
- return $q.resolve();
- }
-
- return promise()
- .then(() => {
- console.log('done');
- vm.scroll.isActive = false;
- /*
- *$timeout(() => {
- * vm.scroll.isActive = false;
- *}, SCROLL_DELAY);
- */
- });
-
-}
-
function scrollHome () {
+ scroll.pause();
+
return page.first()
.then(events => {
if (!events) {
return;
}
- return clear()
- .then(() => prepend(events))
+ return render.clear()
+ .then(() => render.prepend(events))
.then(() => {
- vm.scroll.isActive = false;
+ scroll.setScrollPosition(0);
+ scroll.resume();
});
});
}
function scrollEnd () {
- if (vm.scroll.isLocked) {
+ if (scroll.isLocked()) {
page.bookmark();
-
- vm.scroll.isLocked = false;
- vm.scroll.isActive = false;
+ scroll.unlock();
return;
- } else if (!vm.scroll.isLocked && vm.stream.isActive) {
+ } else if (!scroll.isLocked() && vm.stream.active) {
page.bookmark();
-
- vm.scroll.isActive = true;
- vm.scroll.isLocked = true;
+ scroll.lock();
return;
}
- vm.scroll.isActive = true;
+ scroll.pause();
return page.last()
.then(events => {
@@ -628,36 +289,32 @@ function scrollEnd () {
return;
}
- return clear()
- .then(() => append(events))
+ return render.clear()
+ .then(() => render.append(events))
.then(() => {
- const container = $(ELEMENT_CONTAINER)[0];
-
- container.scrollTop = container.scrollHeight;
- vm.scroll.isActive = false;
+ scroll.setScrollPosition(scroll.getScrollHeight());
+ scroll.resume();
});
});
}
function scrollPageUp () {
- const container = $(ELEMENT_CONTAINER)[0];
- const jump = container.scrollTop - container.offsetHeight;
-
- container.scrollTop = jump;
+ scroll.pageUp();
}
function scrollPageDown () {
- const container = $(ELEMENT_CONTAINER)[0];
- const jump = container.scrollTop + container.offsetHeight;
+ scroll.pageDown();
+}
- container.scrollTop = jump;
+function scrollIsAtRest (isAtRest) {
+ vm.scroll.showBackToTop = !isAtRest;
}
JobsIndexController.$inject = [
'resource',
'JobPageService',
- '$sce',
- '$timeout',
+ 'JobScrollService',
+ 'JobRenderService',
'$scope',
'$compile',
'$q'
diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js
index 6d35022cf4..a0e0b70123 100644
--- a/awx/ui/client/features/output/index.js
+++ b/awx/ui/client/features/output/index.js
@@ -1,11 +1,10 @@
-import JobsStrings from '~features/output/jobs.strings';
-import IndexController from '~features/output/index.controller';
import atLibModels from '~models';
import atLibComponents from '~components';
import Strings from '~features/output/jobs.strings';
import Controller from '~features/output/index.controller';
import PageService from '~features/output/page.service';
+import ScrollService from '~features/output/scroll.service';
const Template = require('~features/output/index.view.html');
@@ -178,6 +177,7 @@ angular
])
.service('JobStrings', Strings)
.service('JobPageService', PageService)
+ .service('JobScrollService', ScrollService)
.run(JobsRun);
export default MODULE_NAME;
diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js
index b82eba4705..e91b80191e 100644
--- a/awx/ui/client/features/output/page.service.js
+++ b/awx/ui/client/features/output/page.service.js
@@ -1,16 +1,10 @@
function JobPageService ($q) {
- this.page = null;
- this.resource = null;
- this.result = null;
- this.buffer = null;
- this.cache = null;
-
this.init = resource => {
this.resource = resource;
this.page = {
- limit: resource.page.pageLimit,
- size: resource.page.size,
+ limit: this.resource.page.pageLimit,
+ size: this.resource.page.size,
current: 0,
index: -1,
count: 0,
@@ -153,13 +147,10 @@ function JobPageService ($q) {
}
this.bookmark = () => {
- console.log('b,current', this.page.current);
if (!this.page.bookmark.active) {
this.page.bookmark.first = this.page.first;
this.page.bookmark.last = this.page.last;
this.page.bookmark.current = this.page.current;
-
- console.log('b,bookmark', this.page.bookmark.current);
this.page.bookmark.active = true;
} else {
this.page.bookmark.active = false;
diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js
index e69de29bb2..235de94198 100644
--- a/awx/ui/client/features/output/render.service.js
+++ b/awx/ui/client/features/output/render.service.js
@@ -0,0 +1,297 @@
+import Ansi from 'ansi-to-html';
+import hasAnsi from 'has-ansi';
+
+const ELEMENT_TBODY = '#atStdoutResultTable';
+const EVENT_START_TASK = 'playbook_on_task_start';
+const EVENT_START_PLAY = 'playbook_on_play_start';
+const EVENT_STATS_PLAY = 'playbook_on_stats';
+const JOB_START = 'playbook_on_start';
+const JOB_END = 'playbook_on_stats';
+
+const EVENT_GROUPS = [
+ EVENT_START_TASK,
+ EVENT_START_PLAY
+];
+
+const TIME_EVENTS = [
+ EVENT_START_TASK,
+ EVENT_START_PLAY,
+ EVENT_STATS_PLAY
+];
+
+const ansi = new Ansi();
+
+function JobRenderService ($q, $sce, $window) {
+ this.init = ({ compile, apply, get }) => {
+ this.parent = null;
+ this.record = {};
+ this.el = $(ELEMENT_TBODY);
+ this.hooks = { get, compile, apply };
+ };
+
+ this.sortByLineNumber = (a, b) => {
+ if (a.start_line > b.start_line) {
+ return 1;
+ }
+
+ if (a.start_line < b.start_line) {
+ return -1;
+ }
+
+ return 0;
+ };
+
+ this.transformEventGroup = events => {
+ let lines = 0;
+ let html = '';
+
+ events.sort(this.sortByLineNumber);
+
+ events.forEach(event => {
+ const line = this.transformEvent(event);
+
+ html += line.html;
+ lines += line.count;
+ });
+
+ return { html, lines };
+ };
+
+ this.transformEvent = event => {
+ if (!event || !event.stdout) {
+ return { html: '', count: 0 };
+ }
+
+ const { stdout } = event;
+ const lines = stdout.split('\r\n');
+
+ let count = lines.length;
+ let ln = event.start_line;
+
+ const current = this.createRecord(ln, lines, event);
+
+ const html = lines.reduce((html, line, i) => {
+ ln++;
+
+ const isLastLine = i === lines.length - 1;
+ let row = this.createRow(current, ln, line);
+
+ if (current && current.isTruncated && isLastLine) {
+ row += this.createRow(current);
+ count++;
+ }
+
+ return `${html}${row}`;
+ }, '');
+
+ return { html, count };
+ };
+
+ this.createRecord = event => {
+ if (!event.uuid) {
+ return null;
+ }
+
+ const info = {
+ id: event.id,
+ line: ln + 1,
+ uuid: event.uuid,
+ level: event.event_level,
+ start: event.start_line,
+ end: event.end_line,
+ isTruncated: (event.end_line - event.start_line) > lines.length,
+ isHost: typeof event.host === 'number'
+ };
+
+ if (event.parent_uuid) {
+ info.parents = getParentEvents(event.parent_uuid);
+ }
+
+ if (info.isTruncated) {
+ info.truncatedAt = event.start_line + lines.length;
+ }
+
+ if (EVENT_GROUPS.includes(event.event)) {
+ info.isParent = true;
+
+ if (event.event_level === 1) {
+ this.parent = event.uuid;
+ }
+
+ if (event.parent_uuid) {
+ if (this.record[event.parent_uuid]) {
+ if (this.record[event.parent_uuid].children &&
+ !this.record[event.parent_uuid].children.includes(event.uuid)) {
+ this.record[event.parent_uuid].children.push(event.uuid);
+ } else {
+ this.record[event.parent_uuid].children = [event.uuid];
+ }
+ }
+ }
+ }
+
+ if (TIME_EVENTS.includes(event.event)) {
+ info.time = this.getTimestamp(event.created);
+ info.line++;
+ }
+
+ this.record[event.uuid] = info;
+
+ return info;
+ };
+
+ this.createRow = (current, ln, content) => {
+ let id = '';
+ let timestamp = '';
+ let tdToggle = '';
+ let tdEvent = '';
+ let classList = '';
+
+ content = content || '';
+
+ if (hasAnsi(content)) {
+ content = ansi.toHtml(content);
+ }
+
+ if (current) {
+ if (current.isParent && current.line === ln) {
+ id = current.uuid;
+ tdToggle = ` | `;
+ }
+
+ if (current.isHost) {
+ tdEvent = `${content} | `;
+ }
+
+ if (current.time && current.line === ln) {
+ timestamp = `${current.time}`;
+ }
+
+ if (current.parents) {
+ classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
+ }
+ }
+
+ if (!tdEvent) {
+ tdEvent = `${content} | `;
+ }
+
+ if (!tdToggle) {
+ tdToggle = ' | ';
+ }
+
+ if (!ln) {
+ ln = '...';
+ }
+
+ return `
+
+ ${tdToggle}
+ | ${ln} |
+ ${tdEvent}
+ ${timestamp} |
+
`;
+ }
+
+ this.getTimestamp = (created) => {
+ const date = new Date(created);
+ const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours();
+ const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes();
+ const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds();
+
+ return `${hour}:${minute}:${second}`;
+ }
+
+ this.getParentEvents = (uuid, list) => {
+ list = list || [];
+
+ if (this.record[uuid]) {
+ list.push(uuid);
+
+ if (this.record[uuid].parents) {
+ list = list.concat(record[uuid].parents);
+ }
+ }
+
+ return list;
+ };
+
+ this.getEvents = () => {
+ return this.hooks.get();
+ };
+
+ this.insert = (events, insert) => {
+ const result = this.transformEventGroup(events);
+ const html = this.sanitize(result.html);
+
+ return this.requestAnimationFrame(() => insert(html))
+ .then(() => this.compile(html))
+ .then(() => result.lines);
+ };
+
+ this.remove = elements => {
+ return this.requestAnimationFrame(() => {
+ elements.empty();
+ elements.remove();
+ });
+ };
+
+ this.requestAnimationFrame = fn => {
+ return $q(resolve => {
+ $window.requestAnimationFrame(() => {
+ if (fn) {
+ fn();
+ }
+
+ return resolve();
+ });
+ });
+ };
+
+ this.compile = html => {
+ this.hooks.compile(html);
+
+ return this.requestAnimationFrame();
+ };
+
+ this.build = () => {
+
+ };
+
+ this.clear = () => {
+ const elements = this.el.children();
+
+ return this.remove(elements);
+ };
+
+ this.shift = lines => {
+ const elements = this.el.children().slice(0, lines);
+
+ return this.remove(elements);
+ };
+
+ this.pop = lines => {
+ const elements = this.el.children().slice(-lines);
+
+ return this.remove(elements);
+ };
+
+ this.prepend = events => {
+ return this.insert(events, html => this.el.prepend(html))
+ };
+
+ this.append = events => {
+ return this.insert(events, html => this.el.append(html))
+ };
+
+ // TODO: stdout from the API should not be trusted.
+ this.sanitize = html => {
+ html = $sce.trustAsHtml(html);
+
+ return $sce.getTrustedHtml(html);
+ };
+}
+
+JobRenderService.$inject = ['$q', '$sce', '$window'];
+
+export default JobRenderService;
diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js
new file mode 100644
index 0000000000..489871e354
--- /dev/null
+++ b/awx/ui/client/features/output/scroll.service.js
@@ -0,0 +1,172 @@
+const ELEMENT_CONTAINER = '.at-Stdout-container';
+const DELAY = 100;
+const THRESHOLD = 0.1;
+
+function JobScrollService ($q, $timeout) {
+ this.init = (hooks) => {
+ this.el = $(ELEMENT_CONTAINER);
+ this.timer = null;
+
+ this.position = {
+ previous: 0,
+ current: 0
+ };
+
+ this.hooks = {
+ isAtRest: hooks.isAtRest,
+ next: hooks.next,
+ previous: hooks.previous
+ };
+
+ this.state = {
+ locked: false,
+ paused: false,
+ top: true
+ };
+
+ this.el.scroll(this.listen);
+ };
+
+ this.listen = () => {
+ if (this.isPaused()) {
+ return;
+ }
+
+ if (this.timer) {
+ $timeout.cancel(this.timer);
+ }
+
+ this.timer = $timeout(this.register, DELAY);
+ };
+
+ this.register = () => {
+ this.pause();
+
+ const height = this.getScrollHeight();
+ const current = this.getScrollPosition();
+ const downward = current > this.position.previous;
+
+ let promise;
+
+ if (downward && this.isBeyondThreshold(downward, current)) {
+ promise = this.hooks.next;
+ } else if (!downward && this.isBeyondThreshold(downward, current)) {
+ promise = this.hooks.previous;
+ }
+
+ if (!promise) {
+ this.setScrollPosition(current);
+ this.isAtRest();
+ this.resume();
+
+ return $q.resolve();
+ }
+
+ return promise()
+ .then(() => {
+ this.setScrollPosition(this.getScrollPosition());
+ this.isAtRest();
+ this.resume();
+ });
+ };
+
+ this.isBeyondThreshold = (downward, current) => {
+ const previous = this.position.previous;
+ const height = this.getScrollHeight();
+
+ if (downward) {
+ current += this.getViewableHeight();
+
+ if (current >= height || ((height - current) / height) < THRESHOLD) {
+ return true;
+ }
+ } else {
+ if (current <= 0 || (current / height) < THRESHOLD) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ this.pageUp = () => {
+ if (this.isPaused()) {
+ return;
+ }
+
+ const top = this.getScrollPosition();
+ const height = this.getViewableHeight();
+
+ this.setScrollPosition(top - height);
+ };
+
+ this.pageDown = () => {
+ if (this.isPaused()) {
+ return;
+ }
+
+ const top = this.getScrollPosition();
+ const height = this.getViewableHeight();
+
+ this.setScrollPosition(top + height);
+ };
+
+ this.getScrollHeight = () => {
+ return this.el[0].scrollHeight;
+ };
+
+ this.getViewableHeight = () => {
+ return this.el[0].offsetHeight;
+ };
+
+ this.getScrollPosition = () => {
+ return this.el[0].scrollTop;
+ };
+
+ this.setScrollPosition = position => {
+ this.position.previous = this.position.current;
+ this.position.current = position;
+ this.el[0].scrollTop = position;
+ this.isAtRest();
+ };
+
+ this.isAtRest = () => {
+ if (this.position.current === 0 && !this.state.top) {
+ this.state.top = true;
+ this.hooks.isAtRest(true);
+ } else if (this.position.current > 0 && this.state.top) {
+ this.state.top = false;
+ this.hooks.isAtRest(false);
+ }
+ };
+
+ this.resume = () => {
+ this.state.paused = false;
+ };
+
+ this.pause = () => {
+ this.state.paused = true;
+ };
+
+ this.isPaused = () => {
+ return this.state.paused;
+ };
+
+ this.lock = () => {
+ this.state.locked = true;
+ this.state.paused = true;
+ };
+
+ this.unlock = () => {
+ this.state.locked = false;
+ this.state.paused = false;
+ };
+
+ this.isLocked = () => {
+ return this.state.locked;
+ };
+}
+
+JobScrollService.$inject = ['$q', '$timeout'];
+
+export default JobScrollService;