implement output follow-scroll behavior

This commit is contained in:
Jake McDermott 2018-08-03 15:46:04 -04:00
parent fed729f101
commit 80d6b0167c
No known key found for this signature in database
GPG Key ID: 9A6F084352C3A0B7
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 {
& > p {
margin: 0;
@ -74,12 +59,6 @@
color: @at-blue;
}
&-menuIconStack--wrapper {
&:hover {
color: @at-blue;
}
}
&-row {
display: flex;

View File

@ -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;

View File

@ -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();
}
attached = true;
}
if (vm.isInFollowMode) {
vm.isFollowing = true;
}
const capacity = slide.getCapacity();
if (capacity <= 0 && !isOnLastPage) {
attached = false;
events = slide.pushFrames(events);
const popCount = events.length - slide.getCapacity();
const isAttached = events.length > 0;
if (!isAttached) {
stopFollowing();
return $q.resolve();
}
return slide.popBack(events.length - capacity)
.then(() => slide.pushFront(events))
if (!vm.isFollowing && canStartFollowing()) {
startFollowing();
}
if (!vm.isFollowing && popCount > 0) {
return $q.resolve();
}
scroll.pause();
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.popBack(popCount)
.then(() => {
if (vm.isFollowing && scroll.isBeyondLowerThreshold()) {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.pushFront(events);
})
.then(() => {
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,17 +168,87 @@ function previous () {
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 () {
if (vm.isFollowing) {
unfollow();
lockFollow = true;
stopFollowing();
return $q.resolve();
}
if (isOnLastPage) {
lockFollow = false;
if (slide.isOnLastPage()) {
scroll.scrollToBottom();
return $q.resolve();
@ -166,22 +257,6 @@ function menuLast () {
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 () {
scroll.moveDown();
}
@ -190,20 +265,6 @@ function up () {
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 () {
vm.isPanelExpanded = !vm.isPanelExpanded;
}
@ -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();
});

View File

@ -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;

View File

@ -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>
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.first()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-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="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 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>

View File

@ -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 = {

View File

@ -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'];

View File

@ -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];
};

View File

@ -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'];

View File

@ -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]);
function getContinuous (events, reverse = false) {
const counters = events.map(({ counter }) => counter);
return (range[1] - range[0]) + (other[1] - other[0]) >= span;
}
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;
}
/**
* 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])];
if (reverse) {
const threshold = Math.max(...missing);
return events.filter(({ counter }) => counter > threshold);
}
const threshold = Math.min(...missing);
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);
this.clear = () => this.storage.clear()
.then(() => {
const [head, tail] = this.getRange();
let popHeight = this.hooks.getScrollHeight();
for (let i = head; i <= tail; ++i) {
this.deleteRecord(i);
}
if (newHead > newTail) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
this.state.head = null;
this.state.tail = null;
return this.chain;
}
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;
};
return $q.resolve();
});
this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
const next = this.getNextRange(displacement);
const [head, tail] = this.getRange();
const tailRoom = this.getMaxCounter() - tail;
const tailDisplacement = Math.min(tailRoom, displacement);
this.chain = this.chain
.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) {
headDisplacement = (newTail - OUTPUT_EVENT_LIMIT) - head;
}
return this.popBack(excess)
.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) => {
const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange();
const headRoom = head - 1;
const headDisplacement = Math.min(headRoom, displacement);
this.chain = this.chain
.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) {
tailDisplacement = tail - (newHead + OUTPUT_EVENT_LIMIT);
}
return this.popFront(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.move([newHead, tail - tailDisplacement]);
};
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.pushBack(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
};
this.getFirst = () => this.clear()
.then(() => this.api.getFirst())
.then(events => this.pushFront(events))
.then(() => this.moveTail(OUTPUT_PAGE_SIZE));
this.getFirst = () => {
this.chain = this.chain
.then(() => this.clear())
.then(() => {
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
this.getLast = () => this.clear()
.then(() => this.api.getLast())
.then(events => this.pushBack(events))
.then(() => this.moveHead(-OUTPUT_PAGE_SIZE));
return this.api.getFirst();
})
.then(events => {
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 = () => {
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 = () => {
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.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();
}

View File

@ -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 = [

View File

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