summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--awx/ui/client/features/output/_index.less21
-rw-r--r--awx/ui/client/features/output/api.events.service.js5
-rw-r--r--awx/ui/client/features/output/index.controller.js295
-rw-r--r--awx/ui/client/features/output/index.js5
-rw-r--r--awx/ui/client/features/output/index.view.html77
-rw-r--r--awx/ui/client/features/output/output.strings.js7
-rw-r--r--awx/ui/client/features/output/page.service.js4
-rw-r--r--awx/ui/client/features/output/render.service.js7
-rw-r--r--awx/ui/client/features/output/scroll.service.js142
-rw-r--r--awx/ui/client/features/output/slide.service.js446
-rw-r--r--awx/ui/client/features/output/status.service.js46
-rw-r--r--awx/ui/client/features/output/stream.service.js2
12 files changed, 672 insertions, 385 deletions
diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less
index 3e14f353a1..bd2c18c14d 100644
--- a/awx/ui/client/features/output/_index.less
+++ b/awx/ui/client/features/output/_index.less
@@ -13,21 +13,6 @@
}
}
- &-menuBottom {
- color: @at-gray-848992;
- font-size: 10px;
- text-transform: uppercase;
- font-weight: bold;
- position: absolute;
- right: 60px;
- bottom: 24px;
- cursor: pointer;
-
- &:hover {
- color: @at-blue;
- }
- }
-
&-menuIconGroup {
& > p {
margin: 0;
@@ -74,12 +59,6 @@
color: @at-blue;
}
- &-menuIconStack--wrapper {
- &:hover {
- color: @at-blue;
- }
- }
-
&-row {
display: flex;
diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js
index 7b7571fa72..9da4f34ba8 100644
--- a/awx/ui/client/features/output/api.events.service.js
+++ b/awx/ui/client/features/output/api.events.service.js
@@ -109,6 +109,11 @@ function JobEventsApiService ($http, $q) {
}
const [low, high] = range;
+
+ if (low > high) {
+ return $q.resolve([]);
+ }
+
const params = merge(this.params, { counter__gte: [low], counter__lte: [high] });
params.page_size = API_MAX_PAGE_SIZE;
diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js
index 5c71a1b861..88d86ca91b 100644
--- a/awx/ui/client/features/output/index.controller.js
+++ b/awx/ui/client/features/output/index.controller.js
@@ -2,6 +2,7 @@
import {
EVENT_START_PLAY,
EVENT_START_TASK,
+ OUTPUT_PAGE_SIZE,
} from './constants';
let $compile;
@@ -54,91 +55,111 @@ function bufferEmpty (min, max) {
return removed;
}
-let attached = false;
-let noframes = false;
-let isOnLastPage = false;
-
+let lockFrames;
function onFrames (events) {
- if (noframes) {
+ if (lockFrames) {
+ events.forEach(bufferAdd);
return $q.resolve();
}
- if (!attached) {
- const minCounter = Math.min(...events.map(({ counter }) => counter));
-
- if (minCounter > slide.getTailCounter() + 1) {
- return $q.resolve();
- }
+ events = slide.pushFrames(events);
+ const popCount = events.length - slide.getCapacity();
+ const isAttached = events.length > 0;
- attached = true;
+ if (!isAttached) {
+ stopFollowing();
+ return $q.resolve();
}
- if (vm.isInFollowMode) {
- vm.isFollowing = true;
+ if (!vm.isFollowing && canStartFollowing()) {
+ startFollowing();
}
- const capacity = slide.getCapacity();
+ if (!vm.isFollowing && popCount > 0) {
+ return $q.resolve();
+ }
- if (capacity <= 0 && !isOnLastPage) {
- attached = false;
+ scroll.pause();
- return $q.resolve();
+ if (vm.isFollowing) {
+ scroll.scrollToBottom();
}
- return slide.popBack(events.length - capacity)
- .then(() => slide.pushFront(events))
+ return slide.popBack(popCount)
+ .then(() => {
+ if (vm.isFollowing) {
+ scroll.scrollToBottom();
+ }
+
+ return slide.pushFront(events);
+ })
.then(() => {
- if (vm.isFollowing && scroll.isBeyondLowerThreshold()) {
+ if (vm.isFollowing) {
scroll.scrollToBottom();
}
+ scroll.resume();
+
return $q.resolve();
});
}
function first () {
+ if (scroll.isPaused()) {
+ return $q.resolve();
+ }
+
scroll.pause();
- unfollow();
+ lockFrames = true;
- attached = false;
- noframes = true;
- isOnLastPage = false;
+ stopFollowing();
- slide.getFirst()
+ return slide.getFirst()
.then(() => {
+ scroll.resetScrollPosition();
+ })
+ .finally(() => {
scroll.resume();
- noframes = false;
-
- return $q.resolve();
+ lockFrames = false;
});
}
function next () {
if (vm.isFollowing) {
+ scroll.scrollToBottom();
+
+ return $q.resolve();
+ }
+
+ if (scroll.isPaused()) {
+ return $q.resolve();
+ }
+
+ if (slide.getTailCounter() >= slide.getMaxCounter()) {
return $q.resolve();
}
scroll.pause();
+ lockFrames = true;
return slide.getNext()
- .then(() => {
- isOnLastPage = slide.isOnLastPage();
- if (isOnLastPage) {
- stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
- if (scroll.isBeyondLowerThreshold()) {
- scroll.scrollToBottom();
- follow();
- }
- }
- })
- .finally(() => scroll.resume());
+ .finally(() => {
+ scroll.resume();
+ lockFrames = false;
+ });
}
function previous () {
+ if (scroll.isPaused()) {
+ return $q.resolve();
+ }
+
scroll.pause();
+ lockFrames = true;
+
+ stopFollowing();
const initialPosition = scroll.getScrollPosition();
- isOnLastPage = false;
return slide.getPrevious()
.then(popHeight => {
@@ -147,61 +168,101 @@ function previous () {
return $q.resolve();
})
- .finally(() => scroll.resume());
+ .finally(() => {
+ scroll.resume();
+ lockFrames = false;
+ });
}
-function menuLast () {
- if (vm.isFollowing) {
- unfollow();
-
- return $q.resolve();
- }
-
- if (isOnLastPage) {
- scroll.scrollToBottom();
-
+function last () {
+ if (scroll.isPaused()) {
return $q.resolve();
}
- return last();
-}
-
-function last () {
scroll.pause();
+ lockFrames = true;
return slide.getLast()
.then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
- scroll.setScrollPosition(scroll.getScrollHeight());
-
- isOnLastPage = true;
- follow();
- scroll.resume();
+ scroll.scrollToBottom();
return $q.resolve();
+ })
+ .finally(() => {
+ scroll.resume();
+ lockFrames = false;
});
}
-function down () {
- scroll.moveDown();
+let followOnce;
+let lockFollow;
+function canStartFollowing () {
+ if (lockFollow) {
+ return false;
+ }
+
+ if (slide.isOnLastPage() && scroll.isBeyondLowerThreshold()) {
+ followOnce = false;
+
+ return true;
+ }
+
+ if (followOnce && // one-time activation from top of first page
+ scroll.isBeyondUpperThreshold() &&
+ slide.getHeadCounter() === 1 &&
+ slide.getTailCounter() >= OUTPUT_PAGE_SIZE) {
+ followOnce = false;
+
+ return true;
+ }
+
+ return false;
}
-function up () {
- scroll.moveUp();
+function startFollowing () {
+ if (vm.isFollowing) {
+ return;
+ }
+
+ vm.isFollowing = true;
+ vm.followTooltip = vm.strings.get('tooltips.MENU_FOLLOWING');
}
-function follow () {
- isOnLastPage = slide.isOnLastPage();
+function stopFollowing () {
+ if (!vm.isFollowing) {
+ return;
+ }
- if (resource.model.get('event_processing_finished')) return;
- if (!isOnLastPage) return;
+ vm.isFollowing = false;
+ vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
+}
+
+function menuLast () {
+ if (vm.isFollowing) {
+ lockFollow = true;
+ stopFollowing();
+
+ return $q.resolve();
+ }
+
+ lockFollow = false;
- vm.isInFollowMode = true;
+ if (slide.isOnLastPage()) {
+ scroll.scrollToBottom();
+
+ return $q.resolve();
+ }
+
+ return last();
}
-function unfollow () {
- vm.isInFollowMode = false;
- vm.isFollowing = false;
+function down () {
+ scroll.moveDown();
+}
+
+function up () {
+ scroll.moveUp();
}
function togglePanelExpand () {
@@ -276,7 +337,10 @@ function showHostDetails (id, uuid) {
$state.go('output.host-event.json', { eventId: id, taskUuid: uuid });
}
+let streaming;
function stopListening () {
+ streaming = null;
+
listeners.forEach(deregister => deregister());
listeners.length = 0;
}
@@ -293,13 +357,46 @@ function startListening () {
listeners.push($scope.$on(resource.ws.summary, (scope, data) => handleSummaryEvent(data)));
}
-function handleStatusEvent (data) {
- status.pushStatusEvent(data);
+function handleJobEvent (data) {
+ streaming = streaming || resource.events
+ .getRange([Math.max(1, data.counter - 50), data.counter + 50])
+ .then(results => {
+ results = results.concat(data);
+
+ const counters = results.map(({ counter }) => counter);
+ const min = Math.min(...counters);
+ const max = Math.max(...counters);
+
+ const missing = [];
+ for (let i = min; i <= max; i++) {
+ if (counters.indexOf(i) < 0) {
+ missing.push(i);
+ }
+ }
+
+ if (missing.length > 0) {
+ const maxMissing = Math.max(...missing);
+ results = results.filter(({ counter }) => counter > maxMissing);
+ }
+
+ stream.setMissingCounterThreshold(max + 1);
+ results.forEach(item => {
+ stream.pushJobEvent(item);
+ status.pushJobEvent(item);
+ });
+
+ return $q.resolve();
+ });
+
+ streaming
+ .then(() => {
+ stream.pushJobEvent(data);
+ status.pushJobEvent(data);
+ });
}
-function handleJobEvent (data) {
- stream.pushJobEvent(data);
- status.pushJobEvent(data);
+function handleStatusEvent (data) {
+ status.pushStatusEvent(data);
}
function handleSummaryEvent (data) {
@@ -315,13 +412,6 @@ function reloadState (params) {
return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' });
}
-function getMaxCounter () {
- const apiMax = resource.events.getMaxCounter();
- const wsMax = stream.getMaxCounter();
-
- return Math.max(apiMax, wsMax);
-}
-
function OutputIndexController (
_$compile_,
_$q_,
@@ -367,28 +457,27 @@ function OutputIndexController (
vm.menu = { last: menuLast, first, down, up };
vm.isMenuExpanded = true;
vm.isFollowing = false;
- vm.isInFollowMode = false;
vm.toggleMenuExpand = toggleMenuExpand;
vm.toggleLineExpand = toggleLineExpand;
vm.showHostDetails = showHostDetails;
vm.toggleLineEnabled = resource.model.get('type') === 'job';
+ vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
render.requestAnimationFrame(() => {
bufferInit();
status.init(resource);
- slide.init(render, resource.events, scroll, { getMaxCounter });
+ slide.init(render, resource.events, scroll);
render.init({ compile, toggles: vm.toggleLineEnabled });
scroll.init({
next,
previous,
- onLeaveLower () {
- unfollow();
- return $q.resolve();
- },
- onEnterLower () {
- follow();
+ onThresholdLeave () {
+ followOnce = false;
+ lockFollow = false;
+ stopFollowing();
+
return $q.resolve();
},
});
@@ -398,15 +487,29 @@ function OutputIndexController (
bufferEmpty,
onFrames,
onStop () {
+ lockFollow = true;
+ stopFollowing();
stopListening();
status.updateStats();
status.dispatch();
- unfollow();
+ status.sync();
+ scroll.stop();
}
});
- startListening();
- status.subscribe(data => { vm.status = data.status; });
+ if (resource.model.get('event_processing_finished')) {
+ followOnce = false;
+ lockFollow = true;
+ lockFrames = true;
+ stopListening();
+ } else {
+ followOnce = true;
+ lockFollow = false;
+ lockFrames = false;
+ resource.events.clearCache();
+ status.subscribe(data => { vm.status = data.status; });
+ startListening();
+ }
return last();
});
diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js
index e4e80a3051..ce016c1f19 100644
--- a/awx/ui/client/features/output/index.js
+++ b/awx/ui/client/features/output/index.js
@@ -1,3 +1,4 @@
+/* eslint camelcase: 0 */
import atLibModels from '~models';
import atLibComponents from '~components';
@@ -41,9 +42,7 @@ function resolveResource (
Wait,
Events,
) {
- const { id, type, handleErrors } = $stateParams;
- const { job_event_search } = $stateParams; // eslint-disable-line camelcase
-
+ const { id, type, handleErrors, job_event_search } = $stateParams;
const { name, key } = getWebSocketResource(type);
let Resource;
diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html
index 511da3c6dc..08df5f714a 100644
--- a/awx/ui/client/features/output/index.view.html
+++ b/awx/ui/client/features/output/index.view.html
@@ -7,45 +7,52 @@
</at-panel>
<at-panel class="at-Stdout" ng-class="{'at-Stdout--fullscreen': vm.isPanelExpanded}">
- <div class="at-Stdout-wrapper">
- <div class="at-Panel-headingTitle">
- <i ng-show="vm.isPanelExpanded && vm.status"
- class="JobResults-statusResultIcon fa icon-job-{{ vm.status }}"></i>
- {{ vm.title }}
- </div>
- <at-job-stats
- resource="vm.resource"
- expanded="vm.isPanelExpanded">
- </at-job-stats>
- <at-job-search reload="vm.reloadState"></at-job-search>
-
- <div class="at-Stdout-menuTop">
- <div class="pull-left" ng-click="vm.toggleMenuExpand()">
- <i class="at-Stdout-menuIcon fa" ng-if="vm.toggleLineEnabled"
- ng-class="{ 'fa-minus': vm.isMenuExpanded, 'fa-plus': !vm.isMenuExpanded }"></i>
+ <div class="at-Stdout-wrapper">
+ <div class="at-Panel-headingTitle">
+ <i ng-show="vm.isPanelExpanded && vm.status"
+ class="JobResults-statusResultIcon fa icon-job-{{ vm.status }}"></i>
+ {{ vm.title }}
</div>
- <div class="pull-right" ng-click="vm.menu.last()">
+ <at-job-stats
+ resource="vm.resource"
+ expanded="vm.isPanelExpanded">
+ </at-job-stats>
+ <at-job-search
+ reload="vm.reloadState">
+ </at-job-search>
+ <div class="at-Stdout-menuTop">
+ <div class="pull-left" ng-click="vm.toggleMenuExpand()">
+ <i class="at-Stdout-menuIcon fa" ng-if="vm.toggleLineEnabled"
+ ng-class="{ 'fa-minus': vm.isMenuExpanded, 'fa-plus': !vm.isMenuExpanded }"></i>
+ </div>
+ <div class="pull-right" ng-click="vm.menu.last()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
- ng-class=" { 'at-Stdout-menuIconStackTop--active': false }"></i>
- </div>
- <div class="pull-right" ng-click="vm.menu.first()">
- <i class="at-Stdout-menuIcon--lg fa fa-angle-double-up"></i>
- </div>
- <div class="pull-right" ng-click="vm.menu.down()">
- <i class="at-Stdout-menuIcon--lg fa fa-angle-down"></i>
+ ng-class="{ 'at-Stdout-menuIcon--active': vm.isFollowing }"
+ data-placement="top"
+ data-trigger="hover"
+ data-tip-watch="vm.followTooltip"
+ aw-tool-tip="{{ vm.followTooltip }}">
+ </i>
+ </div>
+ <div class="pull-right" ng-click="vm.menu.first()">
+ <i class="at-Stdout-menuIcon--lg fa fa-angle-double-up"
+ data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_FIRST') }}"></i>
+ </div>
+ <div class="pull-right" ng-click="vm.menu.down()">
+ <i class="at-Stdout-menuIcon--lg fa fa-angle-down"
+ data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_DOWN') }}"></i>
+ </div>
+ <div class="pull-right" ng-click="vm.menu.up()">
+ <i class="at-Stdout-menuIcon--lg fa fa-angle-up"
+ data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_UP') }}"></i>
+ </div>
+ <div class="at-u-clear"></div>
</div>
- <div class="pull-right" ng-click="vm.menu.up()">
- <i class="at-Stdout-menuIcon--lg fa fa-angle-up"></i>
+ <div class="at-Stdout-container">
+ <div class="at-Stdout-borderHeader"></div>
+ <div id="atStdoutResultTable"></div>
+ <div class="at-Stdout-borderFooter"></div>
</div>
-
- <div class="at-u-clear"></div>
- </div>
-
- <div class="at-Stdout-container">
- <div class="at-Stdout-borderHeader"></div>
- <div id="atStdoutResultTable"></div>
- <div class="at-Stdout-borderFooter"></div>
</div>
- </div>
</at-panel>
</div>
diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js
index 6903a10d5f..538b533cb0 100644
--- a/awx/ui/client/features/output/output.strings.js
+++ b/awx/ui/client/features/output/output.strings.js
@@ -20,7 +20,7 @@ function OutputStrings (BaseString) {
DOWNLOAD_OUTPUT: t.s('Download Output'),
CREDENTIAL: t.s('View the Credential'),
EXPAND_OUTPUT: t.s('Expand Output'),
- EXTRA_VARS: t.s('Read-only view of extra variables added to the job template.'),
+ EXTRA_VARS: t.s('Read-only view of extra variables added to the job template'),
INVENTORY: t.s('View the Inventory'),
JOB_TEMPLATE: t.s('View the Job Template'),
PROJECT: t.s('View the Project'),
@@ -28,6 +28,11 @@ function OutputStrings (BaseString) {
SCHEDULE: t.s('View the Schedule'),
SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'),
USER: t.s('View the User'),
+ MENU_FIRST: t.s('Go to first page'),
+ MENU_DOWN: t.s('Get next page'),
+ MENU_UP: t.s('Get previous page'),
+ MENU_LAST: t.s('Go to last page of available output'),
+ MENU_FOLLOWING: t.s('Currently following output as it arrives. Click to unfollow'),
};
ns.details = {
diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js
index e655420526..786d26ad66 100644
--- a/awx/ui/client/features/output/page.service.js
+++ b/awx/ui/client/features/output/page.service.js
@@ -4,13 +4,14 @@ import { OUTPUT_PAGE_LIMIT } from './constants';
function PageService ($q) {
this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, deleteRecord } = storage;
- const { getPage, getFirst, getLast, getLastPageNumber } = api;
+ const { getPage, getFirst, getLast, getLastPageNumber, getMaxCounter } = api;
this.api = {
getPage,
getFirst,
getLast,
getLastPageNumber,
+ getMaxCounter,
};
this.storage = {
@@ -238,6 +239,7 @@ function PageService ($q) {
this.isOnLastPage = () => this.api.getLastPageNumber() === this.state.tail;
this.getRecordCount = () => Object.keys(this.records).length;
this.getTailCounter = () => this.state.tail;
+ this.getMaxCounter = () => this.api.getMaxCounter();
}
PageService.$inject = ['$q'];
diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js
index 88aea55ecd..a7f44162a7 100644
--- a/awx/ui/client/features/output/render.service.js
+++ b/awx/ui/client/features/output/render.service.js
@@ -69,6 +69,10 @@ function JobRenderService ($q, $sce, $window) {
};
this.transformEvent = event => {
+ if (this.record[event.uuid]) {
+ return { html: '', count: 0 };
+ }
+
if (!event || !event.stdout) {
return { html: '', count: 0 };
}
@@ -127,6 +131,7 @@ function JobRenderService ($q, $sce, $window) {
start: event.start_line,
end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length,
+ lineCount: lines.length,
isHost: this.isHostEvent(event),
};
@@ -167,6 +172,8 @@ function JobRenderService ($q, $sce, $window) {
return info;
};
+ this.getRecord = uuid => this.record[uuid];
+
this.deleteRecord = uuid => {
delete this.record[uuid];
};
diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js
index 192cc40114..4e6e2eff57 100644
--- a/awx/ui/client/features/output/scroll.service.js
+++ b/awx/ui/client/features/output/scroll.service.js
@@ -5,9 +5,12 @@ import {
OUTPUT_SCROLL_THRESHOLD,
} from './constants';
+const MAX_THRASH = 20;
+
function JobScrollService ($q, $timeout) {
- this.init = ({ next, previous, onLeaveLower, onEnterLower }) => {
+ this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(OUTPUT_ELEMENT_CONTAINER);
+ this.chain = $q.resolve();
this.timer = null;
this.position = {
@@ -23,16 +26,35 @@ function JobScrollService ($q, $timeout) {
this.hooks = {
next,
previous,
- onLeaveLower,
- onEnterLower,
+ onThresholdLeave,
};
this.state = {
paused: false,
+ locked: false,
+ hover: false,
+ running: true,
+ thrash: 0,
};
- this.chain = $q.resolve();
this.el.scroll(this.listen);
+ this.el.mouseenter(this.onMouseEnter);
+ this.el.mouseleave(this.onMouseLeave);
+ };
+
+ this.onMouseEnter = () => {
+ this.state.hover = true;
+
+ if (this.state.thrash >= MAX_THRASH) {
+ this.state.thrash = MAX_THRASH - 1;
+ }
+
+ this.unlock();
+ this.unhide();
+ };
+
+ this.onMouseLeave = () => {
+ this.state.hover = false;
};
this.listen = () => {
@@ -40,6 +62,31 @@ function JobScrollService ($q, $timeout) {
return;
}
+ if (this.state.thrash > 0) {
+ if (this.isLocked() || this.state.hover) {
+ this.state.thrash--;
+ }
+ }
+
+ if (!this.state.hover) {
+ this.state.thrash++;
+ }
+
+ if (this.state.thrash >= MAX_THRASH) {
+ if (this.isRunning()) {
+ this.lock();
+ this.hide();
+ }
+ }
+
+ if (this.isLocked()) {
+ return;
+ }
+
+ if (!this.state.hover) {
+ return;
+ }
+
if (this.timer) {
$timeout.cancel(this.timer);
}
@@ -47,17 +94,7 @@ function JobScrollService ($q, $timeout) {
this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY);
};
- this.isBeyondThreshold = () => {
- const position = this.getScrollPosition();
- const viewport = this.getScrollHeight() - this.getViewableHeight();
- const threshold = position / viewport;
-
- return (1 - threshold) < OUTPUT_SCROLL_THRESHOLD;
- };
-
this.register = () => {
- this.pause();
-
const position = this.getScrollPosition();
const viewport = this.getScrollHeight() - this.getViewableHeight();
@@ -70,20 +107,22 @@ function JobScrollService ($q, $timeout) {
const wasBeyondUpperThreshold = this.threshold.previous < OUTPUT_SCROLL_THRESHOLD;
const wasBeyondLowerThreshold = (1 - this.threshold.previous) < OUTPUT_SCROLL_THRESHOLD;
+ const enteredUpperThreshold = isBeyondUpperThreshold && !wasBeyondUpperThreshold;
+ const enteredLowerThreshold = isBeyondLowerThreshold && !wasBeyondLowerThreshold;
+ const leftLowerThreshold = !isBeyondLowerThreshold && wasBeyondLowerThreshold;
+
const transitions = [];
- if (position <= 0 || (isBeyondUpperThreshold && !wasBeyondUpperThreshold)) {
+ if (position <= 0 || enteredUpperThreshold) {
+ transitions.push(this.hooks.onThresholdLeave);
transitions.push(this.hooks.previous);
}
- if (!isBeyondLowerThreshold && wasBeyondLowerThreshold) {
- transitions.push(this.hooks.onLeaveLower);
+ if (leftLowerThreshold) {
+ transitions.push(this.hooks.onThresholdLeave);
}
- if (isBeyondLowerThreshold && !wasBeyondLowerThreshold) {
- transitions.push(this.hooks.onEnterLower);
- transitions.push(this.hooks.next);
- } else if (threshold >= 1) {
+ if (threshold >= 1 || enteredLowerThreshold) {
transitions.push(this.hooks.next);
}
@@ -100,7 +139,6 @@ function JobScrollService ($q, $timeout) {
return this.chain
.then(() => {
- this.resume();
this.setScrollPosition(this.getScrollPosition());
return $q.resolve();
@@ -157,16 +195,70 @@ function JobScrollService ($q, $timeout) {
this.setScrollPosition(this.getScrollHeight());
};
- this.resume = () => {
- this.state.paused = false;
+ this.start = () => {
+ this.state.running = true;
+ };
+
+ this.stop = () => {
+ this.unlock();
+ this.unhide();
+ this.state.running = false;
+ };
+
+ this.lock = () => {
+ this.state.locked = true;
+ };
+
+ this.unlock = () => {
+ this.state.locked = false;
};
this.pause = () => {
this.state.paused = true;
};
- this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
+ this.resume = () => {
+ this.state.paused = false;
+ };
+
+ this.hide = () => {
+ if (this.state.hidden) {
+ return;
+ }
+
+ this.state.hidden = true;
+ this.el.css('overflow-y', 'hidden');
+ };
+
+ this.unhide = () => {
+ if (!this.state.hidden) {
+ return;
+ }
+
+ this.state.hidden = false;
+ this.el.css('overflow-y', 'auto');
+ };
+
+ this.isBeyondLowerThreshold = () => {
+ const position = this.getScrollPosition();
+ const viewport = this.getScrollHeight() - this.getViewableHeight();
+ const threshold = position / viewport;
+
+ return (1 - threshold) < OUTPUT_SCROLL_THRESHOLD;
+ };
+
+ this.isBeyondUpperThreshold = () => {
+ const position = this.getScrollPosition();
+ const viewport = this.getScrollHeight() - this.getViewableHeight();
+ const threshold = position / viewport;
+
+ return threshold < OUTPUT_SCROLL_THRESHOLD;
+ };
+
this.isPaused = () => this.state.paused;
+ this.isRunning = () => this.state.running;
+ this.isLocked = () => this.state.locked;
+ this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
}
JobScrollService.$inject = ['$q', '$timeout'];
diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js
index 3d1a26cfce..8bddc51565 100644
--- a/awx/ui/client/features/output/slide.service.js
+++ b/awx/ui/client/features/output/slide.service.js
@@ -1,85 +1,42 @@
/* eslint camelcase: 0 */
import {
+ API_MAX_PAGE_SIZE,
OUTPUT_EVENT_LIMIT,
OUTPUT_PAGE_SIZE,
} from './constants';
-/**
- * Check if a range overlaps another range
- *
- * @arg {Array} range - A [low, high] range array.
- * @arg {Array} other - A [low, high] range array to be compared with the first.
- *
- * @returns {Boolean} - Indicating that the ranges overlap.
- */
-function checkRangeOverlap (range, other) {
- const span = Math.max(range[1], other[1]) - Math.min(range[0], other[0]);
-
- return (range[1] - range[0]) + (other[1] - other[0]) >= span;
-}
+function getContinuous (events, reverse = false) {
+ const counters = events.map(({ counter }) => counter);
+
+ const min = Math.min(...counters);
+ const max = Math.max(...counters);
-/**
- * Get an array that describes the overlap of two ranges.
- *
- * @arg {Array} range - A [low, high] range array.
- * @arg {Array} other - A [low, high] range array to be compared with the first.
- *
- * @returns {(Array|Boolean)} - Returns false if the ranges aren't overlapping.
- * For overlapping ranges, a length-2 array describing the nature of the overlap
- * is returned. The overlap array describes the position of the second range in
- * terms of how many steps inward (negative) or outward (positive) its sides are
- * relative to the first range.
- *
- * ++45678
- * 234---- => getOverlapArray([4, 8], [2, 4]) = [2, -4]
- *
- * 45678
- * 45--- => getOverlapArray([4, 8], [4, 5]) = [0, -3]
- *
- * 45678
- * -56-- => getOverlapArray([4, 8], [5, 6]) = [-1, -2]
- *
- * 45678
- * --678 => getOverlapArray([4, 8], [6, 8]) = [-2, 0]
- *
- * 456++
- * --678 => getOverlapArray([4, 6], [6, 8]) = [-2, 2]
- *
- * +++456++
- * 12345678 => getOverlapArray([4, 6], [1, 8]) = [3, 2]
- ^
- * 12345678
- * ---456-- => getOverlapArray([1, 8], [4, 6]) = [-3, -2]
- */
-function getOverlapArray (range, other) {
- if (!checkRangeOverlap(range, other)) {
- return false;
+ const missing = [];
+ for (let i = min; i <= max; i++) {
+ if (counters.indexOf(i) < 0) {
+ missing.push(i);
+ }
}
- return [range[0] - other[0], other[1] - range[1]];
-}
+ if (missing.length === 0) {
+ return events;
+ }
+
+ if (reverse) {
+ const threshold = Math.max(...missing);
+
+ return events.filter(({ counter }) => counter > threshold);
+ }
+
+ const threshold = Math.min(...missing);
-/**
- * Apply a minimum and maximum boundary to a range.
- *
- * @arg {Array} range - A [low, high] range array.
- * @arg {Array} other - A [low, high] range array to be applied as a boundary.
- *
- * @returns {(Array)} - Returns a new range array by applying the second range
- * as a boundary to the first.
- *
- * getBoundedRange([2, 6], [2, 8]) = [2, 6]
- * getBoundedRange([1, 9], [2, 8]) = [2, 8]
- * getBoundedRange([4, 9], [2, 8]) = [4, 8]
- */
-function getBoundedRange (range, other) {
- return [Math.max(range[0], other[0]), Math.min(range[1], other[1])];
+ return events.filter(({ counter }) => counter < threshold);
}
function SlidingWindowService ($q) {
- this.init = (storage, api, { getScrollHeight }, { getMaxCounter }) => {
- const { prepend, append, shift, pop, deleteRecord } = storage;
- const { getRange, getFirst, getLast } = api;
+ this.init = (storage, api, { getScrollHeight }) => {
+ const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage;
+ const { getRange, getFirst, getLast, getMaxCounter } = api;
this.api = {
getRange,
@@ -89,10 +46,12 @@ function SlidingWindowService ($q) {
};
this.storage = {
+ clear,
prepend,
append,
shift,
pop,
+ getRecord,
deleteRecord,
};
@@ -100,11 +59,79 @@ function SlidingWindowService ($q) {
getScrollHeight,
};
- this.records = {};
+ this.lines = {};
this.uuids = {};
this.chain = $q.resolve();
- api.clearCache();
+ this.state = { head: null, tail: null };
+ this.cache = { first: null };
+
+ this.buffer = {
+ events: [],
+ min: 0,
+ max: 0,
+ count: 0,
+ };
+ };
+
+ this.getBoundedRange = range => {
+ const bounds = [1, this.getMaxCounter()];
+
+ return [Math.max(range[0], bounds[0]), Math.min(range[1], bounds[1])];
+ };
+
+ this.getNextRange = displacement => {
+ const tail = this.getTailCounter();
+
+ return this.getBoundedRange([tail + 1, tail + 1 + displacement]);
+ };
+
+ this.getPreviousRange = displacement => {
+ const head = this.getHeadCounter();
+
+ return this.getBoundedRange([head - 1 - displacement, head - 1]);
+ };
+
+ this.createRecord = ({ counter, uuid, start_line, end_line }) => {
+ this.lines[counter] = end_line - start_line;
+ this.uuids[counter] = uuid;
+
+ if (this.state.tail === null) {
+ this.state.tail = counter;
+ }
+
+ if (counter > this.state.tail) {
+ this.state.tail = counter;
+ }
+
+ if (this.state.head === null) {
+ this.state.head = counter;
+ }
+
+ if (counter < this.state.head) {
+ this.state.head = counter;
+ }
+ };
+
+ this.deleteRecord = counter => {
+ this.storage.deleteRecord(this.uuids[counter]);
+
+ delete this.uuids[counter];
+ delete this.lines[counter];
+ };
+
+ this.getLineCount = counter => {
+ const record = this.storage.getRecord(counter);
+
+ if (record && record.lineCount) {
+ return record.lineCount;
+ }
+
+ if (this.lines[counter]) {
+ return this.lines[counter];
+ }
+
+ return 0;
};
this.pushFront = events => {
@@ -113,10 +140,7 @@ function SlidingWindowService ($q) {
return this.storage.append(newEvents)
.then(() => {
- newEvents.forEach(({ counter, start_line, end_line, uuid }) => {
- this.records[counter] = { start_line, end_line };
- this.uuids[counter] = uuid;
- });
+ newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
@@ -129,10 +153,7 @@ function SlidingWindowService ($q) {
return this.storage.prepend(newEvents)
.then(() => {
- newEvents.forEach(({ counter, start_line, end_line, uuid }) => {
- this.records[counter] = { start_line, end_line };
- this.uuids[counter] = uuid;
- });
+ newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
@@ -149,18 +170,14 @@ function SlidingWindowService ($q) {
let lines = 0;
for (let i = max; i >= min; --i) {
- if (this.records[i]) {
- lines += (this.records[i].end_line - this.records[i].start_line);
- }
+ lines += this.getLineCount(i);
}
return this.storage.pop(lines)
.then(() => {
for (let i = max; i >= min; --i) {
- delete this.records[i];
-
- this.storage.deleteRecord(this.uuids[i]);
- delete this.uuids[i];
+ this.deleteRecord(i);
+ this.state.tail--;
}
return $q.resolve();
@@ -178,184 +195,219 @@ function SlidingWindowService ($q) {
let lines = 0;
for (let i = min; i <= max; ++i) {
- if (this.records[i]) {
- lines += (this.records[i].end_line - this.records[i].start_line);
- }
+ lines += this.getLineCount(i);
}
return this.storage.shift(lines)
.then(() => {
for (let i = min; i <= max; ++i) {
- delete this.records[i];
-
- this.storage.deleteRecord(this.uuids[i]);
- delete this.uuids[i];
+ this.deleteRecord(i);
+ this.state.head++;
}
return $q.resolve();
});
};
- this.move = ([low, high]) => {
- const bounds = [1, this.getMaxCounter()];
- const [newHead, newTail] = getBoundedRange([low, high], bounds);
-
- let popHeight = this.hooks.getScrollHeight();
-
- if (newHead > newTail) {
- this.chain = this.chain
- .then(() => $q.resolve(popHeight));
+ this.clear = () => this.storage.clear()
+ .then(() => {
+ const [head, tail] = this.getRange();
- return this.chain;
- }
+ for (let i = head; i <= tail; ++i) {
+ this.deleteRecord(i);
+ }
- if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) {
- this.chain = this.chain
- .then(() => $q.resolve(popHeight));
+ this.state.head = null;
+ this.state.tail = null;
- return this.chain;
- }
+ return $q.resolve();
+ });
+ this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
+ const next = this.getNextRange(displacement);
const [head, tail] = this.getRange();
- const overlap = getOverlapArray([head, tail], [newHead, newTail]);
- if (!overlap) {
- this.chain = this.chain
- .then(() => this.clear())
- .then(() => this.api.getRange([newHead, newTail]))
- .then(events => this.pushFront(events));
- }
+ this.chain = this.chain
+ .then(() => this.api.getRange(next))
+ .then(events => {
+ const results = getContinuous(events);
+ const min = Math.min(...results.map(({ counter }) => counter));
- if (overlap && overlap[0] < 0) {
- const popBackCount = Math.abs(overlap[0]);
+ if (min > tail + 1) {
+ return $q.resolve([]);
+ }
- this.chain = this.chain.then(() => this.popBack(popBackCount));
- }
+ return $q.resolve(results);
+ })
+ .then(results => {
+ const count = (tail - head + results.length);
+ const excess = count - OUTPUT_EVENT_LIMIT;
- if (overlap && overlap[1] < 0) {
- const popFrontCount = Math.abs(overlap[1]);
+ return this.popBack(excess)
+ .then(() => {
+ const popHeight = this.hooks.getScrollHeight();
- this.chain = this.chain.then(() => this.popFront(popFrontCount));
- }
+ return this.pushFront(results).then(() => $q.resolve(popHeight));
+ });
+ });
- this.chain = this.chain
- .then(() => {
- popHeight = this.hooks.getScrollHeight();
+ return this.chain;
+ };
- return $q.resolve();
- });
+ this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
+ const previous = this.getPreviousRange(displacement);
+ const [head, tail] = this.getRange();
- if (overlap && overlap[0] > 0) {
- const pushBackRange = [head - overlap[0], head];
+ this.chain = this.chain
+ .then(() => this.api.getRange(previous))
+ .then(events => {
+ const results = getContinuous(events, true);
+ const max = Math.max(...results.map(({ counter }) => counter));
- this.chain = this.chain
- .then(() => this.api.getRange(pushBackRange))
- .then(events => this.pushBack(events));
- }
+ if (head > max + 1) {
+ return $q.resolve([]);
+ }
- if (overlap && overlap[1] > 0) {
- const pushFrontRange = [tail, tail + overlap[1]];
+ return $q.resolve(results);
+ })
+ .then(results => {
+ const count = (tail - head + results.length);
+ const excess = count - OUTPUT_EVENT_LIMIT;
- this.chain = this.chain
- .then(() => this.api.getRange(pushFrontRange))
- .then(events => this.pushFront(events));
- }
+ return this.popFront(excess)
+ .then(() => {
+ const popHeight = this.hooks.getScrollHeight();
- this.chain = this.chain
- .then(() => $q.resolve(popHeight));
+ return this.pushBack(results).then(() => $q.resolve(popHeight));
+ });
+ });
return this.chain;
};
- this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
- const [head, tail] = this.getRange();
+ this.getFirst = () => {
+ this.chain = this.chain
+ .then(() => this.clear())
+ .then(() => {
+ if (this.cache.first) {
+ return $q.resolve(this.cache.first);
+ }
- const tailRoom = this.getMaxCounter() - tail;
- const tailDisplacement = Math.min(tailRoom, displacement);
+ return this.api.getFirst();
+ })
+ .then(events => {
+ if (events.length === OUTPUT_PAGE_SIZE) {
+ this.cache.first = events;
+ }
- const newTail = tail + tailDisplacement;
+ return this.pushFront(events);
+ });
- let headDisplacement = 0;
+ return this.chain
+ .then(() => this.getNext());
+ };
- if (newTail - head > OUTPUT_EVENT_LIMIT) {
- headDisplacement = (newTail - OUTPUT_EVENT_LIMIT) - head;
- }
+ this.getLast = () => {
+ this.chain = this.chain
+ .then(() => this.getFrames())
+ .then(frames => {
+ if (frames.length > 0) {
+ return $q.resolve(frames);
+ }
- return this.move([head + headDisplacement, tail + tailDisplacement]);
- };
+ return this.api.getLast();
+ })
+ .then(events => {
+ const min = Math.min(...events.map(({ counter }) => counter));
- this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
- const [head, tail] = this.getRange();
+ if (min <= this.getTailCounter() + 1) {
+ return this.pushFront(events);
+ }
- const headRoom = head - 1;
- const headDisplacement = Math.min(headRoom, displacement);
+ return this.clear()
+ .then(() => this.pushBack(events));
+ });
- const newHead = head - headDisplacement;
+ return this.chain
+ .then(() => this.getPrevious());
+ };
- let tailDisplacement = 0;
+ this.getTailCounter = () => {
+ if (this.state.tail === null) {
+ return 0;
+ }
- if (tail - newHead > OUTPUT_EVENT_LIMIT) {
- tailDisplacement = tail - (newHead + OUTPUT_EVENT_LIMIT);
+ if (this.state.tail < 0) {
+ return 0;
}
- return this.move([newHead, tail - tailDisplacement]);
+ return this.state.tail;
};
- this.moveHead = displacement => {
- const [head, tail] = this.getRange();
+ this.getHeadCounter = () => {
+ if (this.state.head === null) {
+ return 0;
+ }
- const headRoom = head - 1;
- const headDisplacement = Math.min(headRoom, displacement);
+ if (this.state.head < 0) {
+ return 0;
+ }
- return this.move([head + headDisplacement, tail]);
+ return this.state.head;
};
- this.moveTail = displacement => {
+ this.pushFrames = events => {
+ const frames = this.buffer.events.concat(events);
const [head, tail] = this.getRange();
- const tailRoom = this.getMaxCounter() - tail;
- const tailDisplacement = Math.max(tailRoom, displacement);
+ let min;
+ let max;
+ let count = 0;
- return this.move([head, tail + tailDisplacement]);
- };
+ for (let i = frames.length - 1; i >= 0; i--) {
+ count++;
- this.clear = () => {
- const count = this.getRecordCount();
+ if (count > API_MAX_PAGE_SIZE) {
+ frames.splice(i, 1);
- if (count > 0) {
- this.chain = this.chain
- .then(() => this.popBack(count));
- }
+ count--;
+ continue;
+ }
- return this.chain;
- };
+ if (!min || frames[i].counter < min) {
+ min = frames[i].counter;
+ }
- this.getFirst = () => this.clear()
- .then(() => this.api.getFirst())
- .then(events => this.pushFront(events))
- .then(() => this.moveTail(OUTPUT_PAGE_SIZE));
+ if (!max || frames[i].counter > max) {
+ max = frames[i].counter;
+ }
+ }
- this.getLast = () => this.clear()
- .then(() => this.api.getLast())
- .then(events => this.pushBack(events))
- .then(() => this.moveHead(-OUTPUT_PAGE_SIZE));
+ this.buffer.events = frames;
+ this.buffer.min = min;
+ this.buffer.max = max;
+ this.buffer.count = count;
- this.getTailCounter = () => {
- const tail = Math.max(...Object.keys(this.records));
+ if (min >= head && min <= tail + 1) {
+ return frames.filter(({ counter }) => counter > tail);
+ }
- return Number.isFinite(tail) ? tail : 0;
+ return [];
};
- this.getHeadCounter = () => {
- const head = Math.min(...Object.keys(this.records));
+ this.getFrames = () => $q.resolve(this.buffer.events);
+
+ this.getMaxCounter = () => {
+ if (this.buffer.min) {
+ return this.buffer.min;
+ }
- return Number.isFinite(head) ? head : 0;
+ return this.api.getMaxCounter();
};
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE);
- this.getMaxCounter = () => this.api.getMaxCounter();
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()];
- this.getRecordCount = () => Object.keys(this.records).length;
+ this.getRecordCount = () => Object.keys(this.lines).length;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount();
}
diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js
index 987232d21e..26483ff0e2 100644
--- a/awx/ui/client/features/output/status.service.js
+++ b/awx/ui/client/features/output/status.service.js
@@ -16,6 +16,7 @@ function JobStatusService (moment, message) {
this.subscribe = listener => message.subscribe('status', listener);
this.init = ({ model }) => {
+ this.model = model;
this.created = model.get('created');
this.job = model.get('id');
this.jobType = model.get('type');
@@ -44,6 +45,14 @@ function JobStatusService (moment, message) {
},
};
+ this.initHostStatusCounts({ model });
+ this.initPlaybookCounts({ model });
+
+ this.updateRunningState();
+ this.dispatch();
+ };
+
+ this.initHostStatusCounts = ({ model }) => {
if (model.has('host_status_counts')) {
this.setHostStatusCounts(model.get('host_status_counts'));
} else {
@@ -51,15 +60,14 @@ function JobStatusService (moment, message) {
this.setHostStatusCounts(hostStatusCounts);
}
+ };
+ this.initPlaybookCounts = ({ model }) => {
if (model.has('playbook_counts')) {
this.setPlaybookCounts(model.get('playbook_counts'));
} else {
this.setPlaybookCounts({ task_count: 1, play_count: 1 });
}
-
- this.updateRunningState();
- this.dispatch();
};
this.createHostStatusCounts = status => {
@@ -198,13 +206,16 @@ function JobStatusService (moment, message) {
const isFinished = JOB_STATUS_FINISHED.includes(status);
const isAlreadyFinished = JOB_STATUS_FINISHED.includes(this.state.status);
- if (isAlreadyFinished) {
+ if (isAlreadyFinished && !isFinished) {
return;
}
if ((isExpectingStats && isIncomplete) || (!isExpectingStats && isFinished)) {
if (this.latestTime) {
- this.setFinished(this.latestTime);
+ if (!this.state.finished) {
+ this.setFinished(this.latestTime);
+ }
+
if (!this.state.started && this.state.elapsed) {
this.setStarted(moment(this.latestTime)
.subtract(this.state.elapsed, 'seconds'));
@@ -217,10 +228,14 @@ function JobStatusService (moment, message) {
};
this.setElapsed = elapsed => {
+ if (!elapsed) return;
+
this.state.elapsed = elapsed;
};
this.setStarted = started => {
+ if (!started) return;
+
this.state.started = started;
this.updateRunningState();
};
@@ -234,11 +249,15 @@ function JobStatusService (moment, message) {
};
this.setFinished = time => {
+ if (!time) return;
+
this.state.finished = time;
this.updateRunningState();
};
this.setStatsEvent = data => {
+ if (!data) return;
+
this.statsEvent = data;
};
@@ -267,6 +286,23 @@ function JobStatusService (moment, message) {
this.state.counts.tasks = 0;
this.state.counts.hosts = 0;
};
+
+ this.sync = () => {
+ const { model } = this;
+
+ return model.http.get({ resource: model.get('id') })
+ .then(() => {
+ this.setFinished(model.get('finished'));
+ this.setElapsed(model.get('elapsed'));
+ this.setStarted(model.get('started'));
+ this.setJobStatus(model.get('status'));
+
+ this.initHostStatusCounts({ model });
+ this.initPlaybookCounts({ model });
+
+ this.dispatch();
+ });
+ };
}
JobStatusService.$inject = [
diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js
index 6e8da83420..5b14d26b4b 100644
--- a/awx/ui/client/features/output/stream.service.js
+++ b/awx/ui/client/features/output/stream.service.js
@@ -24,7 +24,7 @@ function OutputStream ($q) {
this.state = {
ending: false,
- ended: false
+ ended: false,
};
this.lag = 0;