implement output follow-scroll behavior

This commit is contained in:
Jake McDermott
2018-08-03 15:46:04 -04:00
parent fed729f101
commit 80d6b0167c
12 changed files with 697 additions and 410 deletions

View File

@@ -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 { &-menuIconGroup {
& > p { & > p {
margin: 0; margin: 0;
@@ -74,12 +59,6 @@
color: @at-blue; color: @at-blue;
} }
&-menuIconStack--wrapper {
&:hover {
color: @at-blue;
}
}
&-row { &-row {
display: flex; display: flex;

View File

@@ -109,6 +109,11 @@ function JobEventsApiService ($http, $q) {
} }
const [low, high] = range; const [low, high] = range;
if (low > high) {
return $q.resolve([]);
}
const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); const params = merge(this.params, { counter__gte: [low], counter__lte: [high] });
params.page_size = API_MAX_PAGE_SIZE; params.page_size = API_MAX_PAGE_SIZE;

View File

@@ -2,6 +2,7 @@
import { import {
EVENT_START_PLAY, EVENT_START_PLAY,
EVENT_START_TASK, EVENT_START_TASK,
OUTPUT_PAGE_SIZE,
} from './constants'; } from './constants';
let $compile; let $compile;
@@ -54,91 +55,111 @@ function bufferEmpty (min, max) {
return removed; return removed;
} }
let attached = false; let lockFrames;
let noframes = false;
let isOnLastPage = false;
function onFrames (events) { function onFrames (events) {
if (noframes) { if (lockFrames) {
events.forEach(bufferAdd);
return $q.resolve(); return $q.resolve();
} }
if (!attached) { events = slide.pushFrames(events);
const minCounter = Math.min(...events.map(({ counter }) => counter)); const popCount = events.length - slide.getCapacity();
const isAttached = events.length > 0;
if (minCounter > slide.getTailCounter() + 1) {
return $q.resolve();
}
attached = true;
}
if (vm.isInFollowMode) {
vm.isFollowing = true;
}
const capacity = slide.getCapacity();
if (capacity <= 0 && !isOnLastPage) {
attached = false;
if (!isAttached) {
stopFollowing();
return $q.resolve(); return $q.resolve();
} }
return slide.popBack(events.length - capacity) if (!vm.isFollowing && canStartFollowing()) {
.then(() => slide.pushFront(events)) startFollowing();
}
if (!vm.isFollowing && popCount > 0) {
return $q.resolve();
}
scroll.pause();
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.popBack(popCount)
.then(() => { .then(() => {
if (vm.isFollowing && scroll.isBeyondLowerThreshold()) { if (vm.isFollowing) {
scroll.scrollToBottom(); scroll.scrollToBottom();
} }
return slide.pushFront(events);
})
.then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
scroll.resume();
return $q.resolve(); return $q.resolve();
}); });
} }
function first () { function first () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause(); scroll.pause();
unfollow(); lockFrames = true;
attached = false; stopFollowing();
noframes = true;
isOnLastPage = false;
slide.getFirst() return slide.getFirst()
.then(() => { .then(() => {
scroll.resetScrollPosition();
})
.finally(() => {
scroll.resume(); scroll.resume();
noframes = false; lockFrames = false;
return $q.resolve();
}); });
} }
function next () { function next () {
if (vm.isFollowing) { if (vm.isFollowing) {
scroll.scrollToBottom();
return $q.resolve();
}
if (scroll.isPaused()) {
return $q.resolve();
}
if (slide.getTailCounter() >= slide.getMaxCounter()) {
return $q.resolve(); return $q.resolve();
} }
scroll.pause(); scroll.pause();
lockFrames = true;
return slide.getNext() return slide.getNext()
.then(() => { .finally(() => {
isOnLastPage = slide.isOnLastPage(); scroll.resume();
if (isOnLastPage) { lockFrames = false;
stream.setMissingCounterThreshold(slide.getTailCounter() + 1); });
if (scroll.isBeyondLowerThreshold()) {
scroll.scrollToBottom();
follow();
}
}
})
.finally(() => scroll.resume());
} }
function previous () { function previous () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause(); scroll.pause();
lockFrames = true;
stopFollowing();
const initialPosition = scroll.getScrollPosition(); const initialPosition = scroll.getScrollPosition();
isOnLastPage = false;
return slide.getPrevious() return slide.getPrevious()
.then(popHeight => { .then(popHeight => {
@@ -147,17 +168,87 @@ function previous () {
return $q.resolve(); return $q.resolve();
}) })
.finally(() => scroll.resume()); .finally(() => {
scroll.resume();
lockFrames = false;
});
}
function last () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
lockFrames = true;
return slide.getLast()
.then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
scroll.scrollToBottom();
return $q.resolve();
})
.finally(() => {
scroll.resume();
lockFrames = false;
});
}
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 startFollowing () {
if (vm.isFollowing) {
return;
}
vm.isFollowing = true;
vm.followTooltip = vm.strings.get('tooltips.MENU_FOLLOWING');
}
function stopFollowing () {
if (!vm.isFollowing) {
return;
}
vm.isFollowing = false;
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
} }
function menuLast () { function menuLast () {
if (vm.isFollowing) { if (vm.isFollowing) {
unfollow(); lockFollow = true;
stopFollowing();
return $q.resolve(); return $q.resolve();
} }
if (isOnLastPage) { lockFollow = false;
if (slide.isOnLastPage()) {
scroll.scrollToBottom(); scroll.scrollToBottom();
return $q.resolve(); return $q.resolve();
@@ -166,22 +257,6 @@ function menuLast () {
return last(); return last();
} }
function last () {
scroll.pause();
return slide.getLast()
.then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
scroll.setScrollPosition(scroll.getScrollHeight());
isOnLastPage = true;
follow();
scroll.resume();
return $q.resolve();
});
}
function down () { function down () {
scroll.moveDown(); scroll.moveDown();
} }
@@ -190,20 +265,6 @@ function up () {
scroll.moveUp(); scroll.moveUp();
} }
function follow () {
isOnLastPage = slide.isOnLastPage();
if (resource.model.get('event_processing_finished')) return;
if (!isOnLastPage) return;
vm.isInFollowMode = true;
}
function unfollow () {
vm.isInFollowMode = false;
vm.isFollowing = false;
}
function togglePanelExpand () { function togglePanelExpand () {
vm.isPanelExpanded = !vm.isPanelExpanded; vm.isPanelExpanded = !vm.isPanelExpanded;
} }
@@ -276,7 +337,10 @@ function showHostDetails (id, uuid) {
$state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); $state.go('output.host-event.json', { eventId: id, taskUuid: uuid });
} }
let streaming;
function stopListening () { function stopListening () {
streaming = null;
listeners.forEach(deregister => deregister()); listeners.forEach(deregister => deregister());
listeners.length = 0; listeners.length = 0;
} }
@@ -293,13 +357,46 @@ function startListening () {
listeners.push($scope.$on(resource.ws.summary, (scope, data) => handleSummaryEvent(data))); listeners.push($scope.$on(resource.ws.summary, (scope, data) => handleSummaryEvent(data)));
} }
function handleStatusEvent (data) { function handleJobEvent (data) {
status.pushStatusEvent(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) { function handleStatusEvent (data) {
stream.pushJobEvent(data); status.pushStatusEvent(data);
status.pushJobEvent(data);
} }
function handleSummaryEvent (data) { function handleSummaryEvent (data) {
@@ -315,13 +412,6 @@ function reloadState (params) {
return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' }); 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 ( function OutputIndexController (
_$compile_, _$compile_,
_$q_, _$q_,
@@ -367,28 +457,27 @@ function OutputIndexController (
vm.menu = { last: menuLast, first, down, up }; vm.menu = { last: menuLast, first, down, up };
vm.isMenuExpanded = true; vm.isMenuExpanded = true;
vm.isFollowing = false; vm.isFollowing = false;
vm.isInFollowMode = false;
vm.toggleMenuExpand = toggleMenuExpand; vm.toggleMenuExpand = toggleMenuExpand;
vm.toggleLineExpand = toggleLineExpand; vm.toggleLineExpand = toggleLineExpand;
vm.showHostDetails = showHostDetails; vm.showHostDetails = showHostDetails;
vm.toggleLineEnabled = resource.model.get('type') === 'job'; vm.toggleLineEnabled = resource.model.get('type') === 'job';
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
render.requestAnimationFrame(() => { render.requestAnimationFrame(() => {
bufferInit(); bufferInit();
status.init(resource); status.init(resource);
slide.init(render, resource.events, scroll, { getMaxCounter }); slide.init(render, resource.events, scroll);
render.init({ compile, toggles: vm.toggleLineEnabled }); render.init({ compile, toggles: vm.toggleLineEnabled });
scroll.init({ scroll.init({
next, next,
previous, previous,
onLeaveLower () { onThresholdLeave () {
unfollow(); followOnce = false;
return $q.resolve(); lockFollow = false;
}, stopFollowing();
onEnterLower () {
follow();
return $q.resolve(); return $q.resolve();
}, },
}); });
@@ -398,15 +487,29 @@ function OutputIndexController (
bufferEmpty, bufferEmpty,
onFrames, onFrames,
onStop () { onStop () {
lockFollow = true;
stopFollowing();
stopListening(); stopListening();
status.updateStats(); status.updateStats();
status.dispatch(); status.dispatch();
unfollow(); status.sync();
scroll.stop();
} }
}); });
startListening(); if (resource.model.get('event_processing_finished')) {
status.subscribe(data => { vm.status = data.status; }); 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(); return last();
}); });

View File

@@ -1,3 +1,4 @@
/* eslint camelcase: 0 */
import atLibModels from '~models'; import atLibModels from '~models';
import atLibComponents from '~components'; import atLibComponents from '~components';
@@ -41,9 +42,7 @@ function resolveResource (
Wait, Wait,
Events, Events,
) { ) {
const { id, type, handleErrors } = $stateParams; const { id, type, handleErrors, job_event_search } = $stateParams;
const { job_event_search } = $stateParams; // eslint-disable-line camelcase
const { name, key } = getWebSocketResource(type); const { name, key } = getWebSocketResource(type);
let Resource; let Resource;

View File

@@ -7,45 +7,52 @@
</at-panel> </at-panel>
<at-panel class="at-Stdout" ng-class="{'at-Stdout--fullscreen': vm.isPanelExpanded}"> <at-panel class="at-Stdout" ng-class="{'at-Stdout--fullscreen': vm.isPanelExpanded}">
<div class="at-Stdout-wrapper"> <div class="at-Stdout-wrapper">
<div class="at-Panel-headingTitle"> <div class="at-Panel-headingTitle">
<i ng-show="vm.isPanelExpanded && vm.status" <i ng-show="vm.isPanelExpanded && vm.status"
class="JobResults-statusResultIcon fa icon-job-{{ vm.status }}"></i> class="JobResults-statusResultIcon fa icon-job-{{ vm.status }}"></i>
{{ vm.title }} {{ 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> </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" <i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
ng-class=" { 'at-Stdout-menuIconStackTop--active': false }"></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>
<div class="pull-right" ng-click="vm.menu.first()"> <div class="at-Stdout-container">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-up"></i> <div class="at-Stdout-borderHeader"></div>
<div id="atStdoutResultTable"></div>
<div class="at-Stdout-borderFooter"></div>
</div> </div>
<div class="pull-right" ng-click="vm.menu.down()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-down"></i>
</div>
<div class="pull-right" ng-click="vm.menu.up()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-up"></i>
</div>
<div class="at-u-clear"></div>
</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> </at-panel>
</div> </div>

View File

@@ -20,7 +20,7 @@ function OutputStrings (BaseString) {
DOWNLOAD_OUTPUT: t.s('Download Output'), DOWNLOAD_OUTPUT: t.s('Download Output'),
CREDENTIAL: t.s('View the Credential'), CREDENTIAL: t.s('View the Credential'),
EXPAND_OUTPUT: t.s('Expand Output'), 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'), INVENTORY: t.s('View the Inventory'),
JOB_TEMPLATE: t.s('View the Job Template'), JOB_TEMPLATE: t.s('View the Job Template'),
PROJECT: t.s('View the Project'), PROJECT: t.s('View the Project'),
@@ -28,6 +28,11 @@ function OutputStrings (BaseString) {
SCHEDULE: t.s('View the Schedule'), SCHEDULE: t.s('View the Schedule'),
SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'), SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'),
USER: t.s('View the User'), 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 = { ns.details = {

View File

@@ -4,13 +4,14 @@ import { OUTPUT_PAGE_LIMIT } from './constants';
function PageService ($q) { function PageService ($q) {
this.init = (storage, api, { getScrollHeight }) => { this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, deleteRecord } = storage; const { prepend, append, shift, pop, deleteRecord } = storage;
const { getPage, getFirst, getLast, getLastPageNumber } = api; const { getPage, getFirst, getLast, getLastPageNumber, getMaxCounter } = api;
this.api = { this.api = {
getPage, getPage,
getFirst, getFirst,
getLast, getLast,
getLastPageNumber, getLastPageNumber,
getMaxCounter,
}; };
this.storage = { this.storage = {
@@ -238,6 +239,7 @@ function PageService ($q) {
this.isOnLastPage = () => this.api.getLastPageNumber() === this.state.tail; this.isOnLastPage = () => this.api.getLastPageNumber() === this.state.tail;
this.getRecordCount = () => Object.keys(this.records).length; this.getRecordCount = () => Object.keys(this.records).length;
this.getTailCounter = () => this.state.tail; this.getTailCounter = () => this.state.tail;
this.getMaxCounter = () => this.api.getMaxCounter();
} }
PageService.$inject = ['$q']; PageService.$inject = ['$q'];

View File

@@ -69,6 +69,10 @@ function JobRenderService ($q, $sce, $window) {
}; };
this.transformEvent = event => { this.transformEvent = event => {
if (this.record[event.uuid]) {
return { html: '', count: 0 };
}
if (!event || !event.stdout) { if (!event || !event.stdout) {
return { html: '', count: 0 }; return { html: '', count: 0 };
} }
@@ -127,6 +131,7 @@ function JobRenderService ($q, $sce, $window) {
start: event.start_line, start: event.start_line,
end: event.end_line, end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length, isTruncated: (event.end_line - event.start_line) > lines.length,
lineCount: lines.length,
isHost: this.isHostEvent(event), isHost: this.isHostEvent(event),
}; };
@@ -167,6 +172,8 @@ function JobRenderService ($q, $sce, $window) {
return info; return info;
}; };
this.getRecord = uuid => this.record[uuid];
this.deleteRecord = uuid => { this.deleteRecord = uuid => {
delete this.record[uuid]; delete this.record[uuid];
}; };

View File

@@ -5,9 +5,12 @@ import {
OUTPUT_SCROLL_THRESHOLD, OUTPUT_SCROLL_THRESHOLD,
} from './constants'; } from './constants';
const MAX_THRASH = 20;
function JobScrollService ($q, $timeout) { function JobScrollService ($q, $timeout) {
this.init = ({ next, previous, onLeaveLower, onEnterLower }) => { this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(OUTPUT_ELEMENT_CONTAINER); this.el = $(OUTPUT_ELEMENT_CONTAINER);
this.chain = $q.resolve();
this.timer = null; this.timer = null;
this.position = { this.position = {
@@ -23,16 +26,35 @@ function JobScrollService ($q, $timeout) {
this.hooks = { this.hooks = {
next, next,
previous, previous,
onLeaveLower, onThresholdLeave,
onEnterLower,
}; };
this.state = { this.state = {
paused: false, paused: false,
locked: false,
hover: false,
running: true,
thrash: 0,
}; };
this.chain = $q.resolve();
this.el.scroll(this.listen); 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 = () => { this.listen = () => {
@@ -40,6 +62,31 @@ function JobScrollService ($q, $timeout) {
return; 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) { if (this.timer) {
$timeout.cancel(this.timer); $timeout.cancel(this.timer);
} }
@@ -47,17 +94,7 @@ function JobScrollService ($q, $timeout) {
this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY); 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.register = () => {
this.pause();
const position = this.getScrollPosition(); const position = this.getScrollPosition();
const viewport = this.getScrollHeight() - this.getViewableHeight(); const viewport = this.getScrollHeight() - this.getViewableHeight();
@@ -70,20 +107,22 @@ function JobScrollService ($q, $timeout) {
const wasBeyondUpperThreshold = this.threshold.previous < OUTPUT_SCROLL_THRESHOLD; const wasBeyondUpperThreshold = this.threshold.previous < OUTPUT_SCROLL_THRESHOLD;
const wasBeyondLowerThreshold = (1 - 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 = []; const transitions = [];
if (position <= 0 || (isBeyondUpperThreshold && !wasBeyondUpperThreshold)) { if (position <= 0 || enteredUpperThreshold) {
transitions.push(this.hooks.onThresholdLeave);
transitions.push(this.hooks.previous); transitions.push(this.hooks.previous);
} }
if (!isBeyondLowerThreshold && wasBeyondLowerThreshold) { if (leftLowerThreshold) {
transitions.push(this.hooks.onLeaveLower); transitions.push(this.hooks.onThresholdLeave);
} }
if (isBeyondLowerThreshold && !wasBeyondLowerThreshold) { if (threshold >= 1 || enteredLowerThreshold) {
transitions.push(this.hooks.onEnterLower);
transitions.push(this.hooks.next);
} else if (threshold >= 1) {
transitions.push(this.hooks.next); transitions.push(this.hooks.next);
} }
@@ -100,7 +139,6 @@ function JobScrollService ($q, $timeout) {
return this.chain return this.chain
.then(() => { .then(() => {
this.resume();
this.setScrollPosition(this.getScrollPosition()); this.setScrollPosition(this.getScrollPosition());
return $q.resolve(); return $q.resolve();
@@ -157,16 +195,70 @@ function JobScrollService ($q, $timeout) {
this.setScrollPosition(this.getScrollHeight()); this.setScrollPosition(this.getScrollHeight());
}; };
this.resume = () => { this.start = () => {
this.state.paused = false; 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.pause = () => {
this.state.paused = true; 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.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']; JobScrollService.$inject = ['$q', '$timeout'];

View File

@@ -1,85 +1,42 @@
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import { import {
API_MAX_PAGE_SIZE,
OUTPUT_EVENT_LIMIT, OUTPUT_EVENT_LIMIT,
OUTPUT_PAGE_SIZE, OUTPUT_PAGE_SIZE,
} from './constants'; } from './constants';
/** function getContinuous (events, reverse = false) {
* Check if a range overlaps another range const counters = events.map(({ counter }) => counter);
*
* @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; const min = Math.min(...counters);
} const max = Math.max(...counters);
/** const missing = [];
* Get an array that describes the overlap of two ranges. for (let i = min; i <= max; i++) {
* if (counters.indexOf(i) < 0) {
* @arg {Array} range - A [low, high] range array. missing.push(i);
* @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;
} }
return [range[0] - other[0], other[1] - range[1]]; if (missing.length === 0) {
} return events;
}
/** if (reverse) {
* Apply a minimum and maximum boundary to a range. const threshold = Math.max(...missing);
*
* @arg {Array} range - A [low, high] range array. return events.filter(({ counter }) => counter > threshold);
* @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 const threshold = Math.min(...missing);
* as a boundary to the first.
* return events.filter(({ counter }) => counter < threshold);
* 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])];
} }
function SlidingWindowService ($q) { function SlidingWindowService ($q) {
this.init = (storage, api, { getScrollHeight }, { getMaxCounter }) => { this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, deleteRecord } = storage; const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage;
const { getRange, getFirst, getLast } = api; const { getRange, getFirst, getLast, getMaxCounter } = api;
this.api = { this.api = {
getRange, getRange,
@@ -89,10 +46,12 @@ function SlidingWindowService ($q) {
}; };
this.storage = { this.storage = {
clear,
prepend, prepend,
append, append,
shift, shift,
pop, pop,
getRecord,
deleteRecord, deleteRecord,
}; };
@@ -100,11 +59,79 @@ function SlidingWindowService ($q) {
getScrollHeight, getScrollHeight,
}; };
this.records = {}; this.lines = {};
this.uuids = {}; this.uuids = {};
this.chain = $q.resolve(); 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 => { this.pushFront = events => {
@@ -113,10 +140,7 @@ function SlidingWindowService ($q) {
return this.storage.append(newEvents) return this.storage.append(newEvents)
.then(() => { .then(() => {
newEvents.forEach(({ counter, start_line, end_line, uuid }) => { newEvents.forEach(event => this.createRecord(event));
this.records[counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve(); return $q.resolve();
}); });
@@ -129,10 +153,7 @@ function SlidingWindowService ($q) {
return this.storage.prepend(newEvents) return this.storage.prepend(newEvents)
.then(() => { .then(() => {
newEvents.forEach(({ counter, start_line, end_line, uuid }) => { newEvents.forEach(event => this.createRecord(event));
this.records[counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve(); return $q.resolve();
}); });
@@ -149,18 +170,14 @@ function SlidingWindowService ($q) {
let lines = 0; let lines = 0;
for (let i = max; i >= min; --i) { for (let i = max; i >= min; --i) {
if (this.records[i]) { lines += this.getLineCount(i);
lines += (this.records[i].end_line - this.records[i].start_line);
}
} }
return this.storage.pop(lines) return this.storage.pop(lines)
.then(() => { .then(() => {
for (let i = max; i >= min; --i) { for (let i = max; i >= min; --i) {
delete this.records[i]; this.deleteRecord(i);
this.state.tail--;
this.storage.deleteRecord(this.uuids[i]);
delete this.uuids[i];
} }
return $q.resolve(); return $q.resolve();
@@ -178,184 +195,219 @@ function SlidingWindowService ($q) {
let lines = 0; let lines = 0;
for (let i = min; i <= max; ++i) { for (let i = min; i <= max; ++i) {
if (this.records[i]) { lines += this.getLineCount(i);
lines += (this.records[i].end_line - this.records[i].start_line);
}
} }
return this.storage.shift(lines) return this.storage.shift(lines)
.then(() => { .then(() => {
for (let i = min; i <= max; ++i) { for (let i = min; i <= max; ++i) {
delete this.records[i]; this.deleteRecord(i);
this.state.head++;
this.storage.deleteRecord(this.uuids[i]);
delete this.uuids[i];
} }
return $q.resolve(); return $q.resolve();
}); });
}; };
this.move = ([low, high]) => { this.clear = () => this.storage.clear()
const bounds = [1, this.getMaxCounter()]; .then(() => {
const [newHead, newTail] = getBoundedRange([low, high], bounds); const [head, tail] = this.getRange();
let popHeight = this.hooks.getScrollHeight(); for (let i = head; i <= tail; ++i) {
this.deleteRecord(i);
}
if (newHead > newTail) { this.state.head = null;
this.chain = this.chain this.state.tail = null;
.then(() => $q.resolve(popHeight));
return this.chain; return $q.resolve();
} });
if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
}
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));
}
if (overlap && overlap[0] < 0) {
const popBackCount = Math.abs(overlap[0]);
this.chain = this.chain.then(() => this.popBack(popBackCount));
}
if (overlap && overlap[1] < 0) {
const popFrontCount = Math.abs(overlap[1]);
this.chain = this.chain.then(() => this.popFront(popFrontCount));
}
this.chain = this.chain
.then(() => {
popHeight = this.hooks.getScrollHeight();
return $q.resolve();
});
if (overlap && overlap[0] > 0) {
const pushBackRange = [head - overlap[0], head];
this.chain = this.chain
.then(() => this.api.getRange(pushBackRange))
.then(events => this.pushBack(events));
}
if (overlap && overlap[1] > 0) {
const pushFrontRange = [tail, tail + overlap[1]];
this.chain = this.chain
.then(() => this.api.getRange(pushFrontRange))
.then(events => this.pushFront(events));
}
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
};
this.getNext = (displacement = OUTPUT_PAGE_SIZE) => { this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
const next = this.getNextRange(displacement);
const [head, tail] = this.getRange(); const [head, tail] = this.getRange();
const tailRoom = this.getMaxCounter() - tail; this.chain = this.chain
const tailDisplacement = Math.min(tailRoom, displacement); .then(() => this.api.getRange(next))
.then(events => {
const results = getContinuous(events);
const min = Math.min(...results.map(({ counter }) => counter));
const newTail = tail + tailDisplacement; if (min > tail + 1) {
return $q.resolve([]);
}
let headDisplacement = 0; return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
if (newTail - head > OUTPUT_EVENT_LIMIT) { return this.popBack(excess)
headDisplacement = (newTail - OUTPUT_EVENT_LIMIT) - head; .then(() => {
} const popHeight = this.hooks.getScrollHeight();
return this.move([head + headDisplacement, tail + tailDisplacement]); return this.pushFront(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
}; };
this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => { this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange(); const [head, tail] = this.getRange();
const headRoom = head - 1; this.chain = this.chain
const headDisplacement = Math.min(headRoom, displacement); .then(() => this.api.getRange(previous))
.then(events => {
const results = getContinuous(events, true);
const max = Math.max(...results.map(({ counter }) => counter));
const newHead = head - headDisplacement; if (head > max + 1) {
return $q.resolve([]);
}
let tailDisplacement = 0; return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
if (tail - newHead > OUTPUT_EVENT_LIMIT) { return this.popFront(excess)
tailDisplacement = tail - (newHead + OUTPUT_EVENT_LIMIT); .then(() => {
} const popHeight = this.hooks.getScrollHeight();
return this.move([newHead, tail - tailDisplacement]); return this.pushBack(results).then(() => $q.resolve(popHeight));
}; });
});
this.moveHead = displacement => {
const [head, tail] = this.getRange();
const headRoom = head - 1;
const headDisplacement = Math.min(headRoom, displacement);
return this.move([head + headDisplacement, tail]);
};
this.moveTail = displacement => {
const [head, tail] = this.getRange();
const tailRoom = this.getMaxCounter() - tail;
const tailDisplacement = Math.max(tailRoom, displacement);
return this.move([head, tail + tailDisplacement]);
};
this.clear = () => {
const count = this.getRecordCount();
if (count > 0) {
this.chain = this.chain
.then(() => this.popBack(count));
}
return this.chain; return this.chain;
}; };
this.getFirst = () => this.clear() this.getFirst = () => {
.then(() => this.api.getFirst()) this.chain = this.chain
.then(events => this.pushFront(events)) .then(() => this.clear())
.then(() => this.moveTail(OUTPUT_PAGE_SIZE)); .then(() => {
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
this.getLast = () => this.clear() return this.api.getFirst();
.then(() => this.api.getLast()) })
.then(events => this.pushBack(events)) .then(events => {
.then(() => this.moveHead(-OUTPUT_PAGE_SIZE)); if (events.length === OUTPUT_PAGE_SIZE) {
this.cache.first = events;
}
return this.pushFront(events);
});
return this.chain
.then(() => this.getNext());
};
this.getLast = () => {
this.chain = this.chain
.then(() => this.getFrames())
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
return this.api.getLast();
})
.then(events => {
const min = Math.min(...events.map(({ counter }) => counter));
if (min <= this.getTailCounter() + 1) {
return this.pushFront(events);
}
return this.clear()
.then(() => this.pushBack(events));
});
return this.chain
.then(() => this.getPrevious());
};
this.getTailCounter = () => { this.getTailCounter = () => {
const tail = Math.max(...Object.keys(this.records)); if (this.state.tail === null) {
return 0;
}
return Number.isFinite(tail) ? tail : 0; if (this.state.tail < 0) {
return 0;
}
return this.state.tail;
}; };
this.getHeadCounter = () => { this.getHeadCounter = () => {
const head = Math.min(...Object.keys(this.records)); if (this.state.head === null) {
return 0;
}
return Number.isFinite(head) ? head : 0; if (this.state.head < 0) {
return 0;
}
return this.state.head;
};
this.pushFrames = events => {
const frames = this.buffer.events.concat(events);
const [head, tail] = this.getRange();
let min;
let max;
let count = 0;
for (let i = frames.length - 1; i >= 0; i--) {
count++;
if (count > API_MAX_PAGE_SIZE) {
frames.splice(i, 1);
count--;
continue;
}
if (!min || frames[i].counter < min) {
min = frames[i].counter;
}
if (!max || frames[i].counter > max) {
max = frames[i].counter;
}
}
this.buffer.events = frames;
this.buffer.min = min;
this.buffer.max = max;
this.buffer.count = count;
if (min >= head && min <= tail + 1) {
return frames.filter(({ counter }) => counter > tail);
}
return [];
};
this.getFrames = () => $q.resolve(this.buffer.events);
this.getMaxCounter = () => {
if (this.buffer.min) {
return this.buffer.min;
}
return this.api.getMaxCounter();
}; };
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE); this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE);
this.getMaxCounter = () => this.api.getMaxCounter();
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; 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(); this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount();
} }

View File

@@ -16,6 +16,7 @@ function JobStatusService (moment, message) {
this.subscribe = listener => message.subscribe('status', listener); this.subscribe = listener => message.subscribe('status', listener);
this.init = ({ model }) => { this.init = ({ model }) => {
this.model = model;
this.created = model.get('created'); this.created = model.get('created');
this.job = model.get('id'); this.job = model.get('id');
this.jobType = model.get('type'); 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')) { if (model.has('host_status_counts')) {
this.setHostStatusCounts(model.get('host_status_counts')); this.setHostStatusCounts(model.get('host_status_counts'));
} else { } else {
@@ -51,15 +60,14 @@ function JobStatusService (moment, message) {
this.setHostStatusCounts(hostStatusCounts); this.setHostStatusCounts(hostStatusCounts);
} }
};
this.initPlaybookCounts = ({ model }) => {
if (model.has('playbook_counts')) { if (model.has('playbook_counts')) {
this.setPlaybookCounts(model.get('playbook_counts')); this.setPlaybookCounts(model.get('playbook_counts'));
} else { } else {
this.setPlaybookCounts({ task_count: 1, play_count: 1 }); this.setPlaybookCounts({ task_count: 1, play_count: 1 });
} }
this.updateRunningState();
this.dispatch();
}; };
this.createHostStatusCounts = status => { this.createHostStatusCounts = status => {
@@ -198,13 +206,16 @@ function JobStatusService (moment, message) {
const isFinished = JOB_STATUS_FINISHED.includes(status); const isFinished = JOB_STATUS_FINISHED.includes(status);
const isAlreadyFinished = JOB_STATUS_FINISHED.includes(this.state.status); const isAlreadyFinished = JOB_STATUS_FINISHED.includes(this.state.status);
if (isAlreadyFinished) { if (isAlreadyFinished && !isFinished) {
return; return;
} }
if ((isExpectingStats && isIncomplete) || (!isExpectingStats && isFinished)) { if ((isExpectingStats && isIncomplete) || (!isExpectingStats && isFinished)) {
if (this.latestTime) { if (this.latestTime) {
this.setFinished(this.latestTime); if (!this.state.finished) {
this.setFinished(this.latestTime);
}
if (!this.state.started && this.state.elapsed) { if (!this.state.started && this.state.elapsed) {
this.setStarted(moment(this.latestTime) this.setStarted(moment(this.latestTime)
.subtract(this.state.elapsed, 'seconds')); .subtract(this.state.elapsed, 'seconds'));
@@ -217,10 +228,14 @@ function JobStatusService (moment, message) {
}; };
this.setElapsed = elapsed => { this.setElapsed = elapsed => {
if (!elapsed) return;
this.state.elapsed = elapsed; this.state.elapsed = elapsed;
}; };
this.setStarted = started => { this.setStarted = started => {
if (!started) return;
this.state.started = started; this.state.started = started;
this.updateRunningState(); this.updateRunningState();
}; };
@@ -234,11 +249,15 @@ function JobStatusService (moment, message) {
}; };
this.setFinished = time => { this.setFinished = time => {
if (!time) return;
this.state.finished = time; this.state.finished = time;
this.updateRunningState(); this.updateRunningState();
}; };
this.setStatsEvent = data => { this.setStatsEvent = data => {
if (!data) return;
this.statsEvent = data; this.statsEvent = data;
}; };
@@ -267,6 +286,23 @@ function JobStatusService (moment, message) {
this.state.counts.tasks = 0; this.state.counts.tasks = 0;
this.state.counts.hosts = 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 = [ JobStatusService.$inject = [

View File

@@ -24,7 +24,7 @@ function OutputStream ($q) {
this.state = { this.state = {
ending: false, ending: false,
ended: false ended: false,
}; };
this.lag = 0; this.lag = 0;