Merge pull request #2740 from jakemcdermott/fix-2241

implement auto-follow-scroll behavior for job output
This commit is contained in:
Jake McDermott 2018-08-08 22:43:45 -04:00 committed by GitHub
commit 41f88fd2f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 899 additions and 544 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;

View File

@ -1,10 +1,12 @@
const API_PAGE_SIZE = 200;
const PAGE_SIZE = 50;
const ORDER_BY = 'counter';
import {
API_MAX_PAGE_SIZE,
OUTPUT_ORDER_BY,
OUTPUT_PAGE_SIZE,
} from './constants';
const BASE_PARAMS = {
page_size: PAGE_SIZE,
order_by: ORDER_BY,
page_size: OUTPUT_PAGE_SIZE,
order_by: OUTPUT_ORDER_BY,
};
const merge = (...objs) => _.merge({}, ...objs);
@ -18,12 +20,6 @@ function JobEventsApiService ($http, $q) {
this.cache = {};
};
this.clearCache = () => {
Object.keys(this.cache).forEach(key => {
delete this.cache[key];
});
};
this.fetch = () => this.getLast()
.then(results => {
this.cache.last = results;
@ -31,20 +27,31 @@ function JobEventsApiService ($http, $q) {
return this;
});
this.clearCache = () => {
Object.keys(this.cache).forEach(key => {
delete this.cache[key];
});
};
this.pushMaxCounter = events => {
const maxCounter = Math.max(...events.map(({ counter }) => counter));
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
return maxCounter;
};
this.getFirst = () => {
const page = 1;
const params = merge(this.params, { page });
const params = merge(this.params, { page: 1 });
return $http.get(this.endpoint, { params })
.then(({ data }) => {
const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
this.state.count = count;
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
this.pushMaxCounter(results);
return results;
});
@ -60,13 +67,9 @@ function JobEventsApiService ($http, $q) {
return $http.get(this.endpoint, { params })
.then(({ data }) => {
const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
this.state.count = count;
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
this.pushMaxCounter(results);
return results;
});
@ -77,17 +80,16 @@ function JobEventsApiService ($http, $q) {
return $q.resolve(this.cache.last);
}
const params = merge(this.params, { page: 1, order_by: `-${ORDER_BY}` });
const params = merge(this.params, { page: 1, order_by: `-${OUTPUT_ORDER_BY}` });
return $http.get(this.endpoint, { params })
.then(({ data }) => {
const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
let rotated = results;
if (count > PAGE_SIZE) {
rotated = results.splice(count % PAGE_SIZE);
if (count > OUTPUT_PAGE_SIZE) {
rotated = results.splice(count % OUTPUT_PAGE_SIZE);
if (results.length > 0) {
rotated = results;
@ -95,10 +97,7 @@ function JobEventsApiService ($http, $q) {
}
this.state.count = count;
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
this.pushMaxCounter(results);
return rotated;
});
@ -110,24 +109,26 @@ 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_PAGE_SIZE;
params.page_size = API_MAX_PAGE_SIZE;
return $http.get(this.endpoint, { params })
.then(({ data }) => {
const { results } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
this.pushMaxCounter(results);
return results;
});
};
this.getLastPageNumber = () => Math.ceil(this.state.count / PAGE_SIZE);
this.getLastPageNumber = () => Math.ceil(this.state.count / OUTPUT_PAGE_SIZE);
this.getMaxCounter = () => this.state.maxCounter;
}

View File

@ -0,0 +1,30 @@
export const API_MAX_PAGE_SIZE = 200;
export const API_ROOT = '/api/v2/';
export const EVENT_START_TASK = 'playbook_on_task_start';
export const EVENT_START_PLAY = 'playbook_on_play_start';
export const EVENT_START_PLAYBOOK = 'playbook_on_start';
export const EVENT_STATS_PLAY = 'playbook_on_stats';
export const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped'];
export const JOB_STATUS_COMPLETE = ['successful', 'failed', 'unknown'];
export const JOB_STATUS_INCOMPLETE = ['canceled', 'error'];
export const JOB_STATUS_UNSUCCESSFUL = ['failed'].concat(JOB_STATUS_INCOMPLETE);
export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPLETE);
export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container';
export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable';
export const OUTPUT_MAX_LAG = 120;
export const OUTPUT_ORDER_BY = 'counter';
export const OUTPUT_PAGE_CACHE = true;
export const OUTPUT_PAGE_LIMIT = 5;
export const OUTPUT_PAGE_SIZE = 50;
export const OUTPUT_SCROLL_DELAY = 100;
export const OUTPUT_SCROLL_THRESHOLD = 0.1;
export const OUTPUT_SEARCH_DOCLINK = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html';
export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play'];
export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01'];
export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE;
export const WS_PREFIX = 'ws';

View File

@ -1,6 +1,9 @@
/* eslint camelcase: 0 */
const EVENT_START_TASK = 'playbook_on_task_start';
const EVENT_START_PLAY = 'playbook_on_play_start';
import {
EVENT_START_PLAY,
EVENT_START_TASK,
OUTPUT_PAGE_SIZE,
} from './constants';
let $compile;
let $q;
@ -20,9 +23,6 @@ const bufferState = [0, 0]; // [length, count]
const listeners = [];
const rx = [];
let following = false;
let attach = true;
function bufferInit () {
rx.length = 0;
@ -55,57 +55,109 @@ function bufferEmpty (min, max) {
return removed;
}
let lockFrames;
function onFrames (events) {
if (!following) {
const minCounter = Math.min(...events.map(({ counter }) => counter));
// attachment range
const max = slide.getTailCounter() + 1;
const min = Math.max(1, slide.getHeadCounter(), max - 50);
if (minCounter > max || minCounter < min) {
return $q.resolve();
}
if (!attach) {
return $q.resolve();
}
follow();
if (lockFrames) {
events.forEach(bufferAdd);
return $q.resolve();
}
const capacity = slide.getCapacity();
events = slide.pushFrames(events);
const popCount = events.length - slide.getCapacity();
const isAttached = events.length > 0;
return slide.popBack(events.length - capacity)
.then(() => slide.pushFront(events))
.then(() => {
scroll.setScrollPosition(scroll.getScrollHeight());
if (!isAttached) {
stopFollowing();
return $q.resolve();
}
return $q.resolve();
});
}
if (!vm.isFollowing && canStartFollowing()) {
startFollowing();
}
if (!vm.isFollowing && popCount > 0) {
return $q.resolve();
}
function first () {
unfollow();
scroll.pause();
return slide.getFirst()
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.popBack(popCount)
.then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.pushFront(events);
})
.then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
scroll.resume();
return $q.resolve();
});
}
function next () {
function first () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
lockFrames = true;
stopFollowing();
return slide.getFirst()
.then(() => {
scroll.resetScrollPosition();
})
.finally(() => {
scroll.resume();
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()
.finally(() => scroll.resume());
.finally(() => {
scroll.resume();
lockFrames = false;
});
}
function previous () {
unfollow();
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
lockFrames = true;
stopFollowing();
const initialPosition = scroll.getScrollPosition();
@ -116,51 +168,101 @@ 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.setScrollPosition(scroll.getScrollHeight());
attach = true;
scroll.resume();
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) {
lockFollow = true;
stopFollowing();
return $q.resolve();
}
lockFollow = false;
if (slide.isOnLastPage()) {
scroll.scrollToBottom();
return $q.resolve();
}
return last();
}
function down () {
scroll.moveDown();
}
function up () {
if (following) {
unfollow();
} else {
scroll.moveUp();
}
}
function follow () {
scroll.pause();
scroll.hide();
following = true;
vm.isFollowing = following;
}
function unfollow () {
attach = false;
following = false;
vm.isFollowing = following;
scroll.unhide();
scroll.resume();
scroll.moveUp();
}
function togglePanelExpand () {
@ -235,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;
}
@ -252,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) {
@ -316,37 +454,62 @@ function OutputIndexController (
vm.togglePanelExpand = togglePanelExpand;
// Stdout Navigation
vm.menu = { last, first, down, up };
vm.menu = { last: menuLast, first, down, up };
vm.isMenuExpanded = true;
vm.isFollowing = following;
vm.isFollowing = 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);
render.init({ compile, toggles: vm.toggleLineEnabled });
scroll.init({ previous, next });
scroll.init({
next,
previous,
onThresholdLeave () {
followOnce = false;
lockFollow = false;
stopFollowing();
return $q.resolve();
},
});
stream.init({
bufferAdd,
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';
@ -18,16 +19,15 @@ import SearchComponent from '~features/output/search.component';
import StatsComponent from '~features/output/stats.component';
import HostEvent from './host-event/index';
const Template = require('~features/output/index.view.html');
import {
API_ROOT,
OUTPUT_ORDER_BY,
OUTPUT_PAGE_SIZE,
WS_PREFIX,
} from './constants';
const MODULE_NAME = 'at.features.output';
const PAGE_CACHE = true;
const PAGE_LIMIT = 5;
const PAGE_SIZE = 50;
const ORDER_BY = 'counter';
const WS_PREFIX = 'ws';
const API_ROOT = '/api/v2/';
const Template = require('~features/output/index.view.html');
function resolveResource (
$state,
@ -42,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;
@ -80,23 +78,16 @@ function resolveResource (
}
const params = {
page_size: PAGE_SIZE,
order_by: ORDER_BY,
};
const config = {
params,
pageCache: PAGE_CACHE,
pageLimit: PAGE_LIMIT,
page_size: OUTPUT_PAGE_SIZE,
order_by: OUTPUT_ORDER_BY,
};
if (job_event_search) { // eslint-disable-line camelcase
const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search));
Object.assign(config.params, query);
Object.assign(params, query);
}
Events.init(`${API_ROOT}${related}`, config.params);
Events.init(`${API_ROOT}${related}`, params);
Wait('start');
const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()])

View File

@ -7,56 +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 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>
<div class="pull-right" ng-click="vm.menu.last()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
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="at-Stdout-container">
<div class="at-Stdout-borderHeader"></div>
<div id="atStdoutResultTable"></div>
<div class="at-Stdout-borderFooter"></div>
</div>
</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 class="pull-right" ng-click="vm.menu.last()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
ng-class=" { 'at-Stdout-menuIcon--active': vm.menu.isLocked }"></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>
</div>
<div class="pull-right" ng-click="vm.menu.up()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-up"
ng-class=" { 'at-Stdout-menuIcon--active': vm.isFollowing }"></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 ng-show="vm.menu.showBackToTop" class="at-Stdout-menuBottom">
<div class="at-Stdout-menuIconGroup" ng-click="vm.menu.home()">
<p class="pull-left"><i class="fa fa-angle-double-up"></i></p>
<p class="pull-right">{{:: vm.strings.get('stdout.BACK_TO_TOP') }}</p>
</div>
<div class="at-u-clear"></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

@ -1,16 +1,17 @@
/* eslint camelcase: 0 */
const PAGE_LIMIT = 5;
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 = {
@ -150,7 +151,7 @@ function PageService ($q) {
const pageCount = this.state.head - this.state.tail;
if (pageCount >= PAGE_LIMIT) {
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popBack())
.then(() => {
@ -185,7 +186,7 @@ function PageService ($q) {
const pageCount = this.state.head - this.state.tail;
if (pageCount >= PAGE_LIMIT) {
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popFront())
.then(() => {
@ -235,8 +236,10 @@ function PageService ($q) {
})
.then(() => this.getNext());
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

@ -1,20 +1,22 @@
import Ansi from 'ansi-to-html';
import Entities from 'html-entities';
const ELEMENT_TBODY = '#atStdoutResultTable';
const EVENT_START_TASK = 'playbook_on_task_start';
const EVENT_START_PLAY = 'playbook_on_play_start';
const EVENT_STATS_PLAY = 'playbook_on_stats';
import {
EVENT_START_PLAY,
EVENT_STATS_PLAY,
EVENT_START_TASK,
OUTPUT_ELEMENT_TBODY,
} from './constants';
const EVENT_GROUPS = [
EVENT_START_TASK,
EVENT_START_PLAY
EVENT_START_PLAY,
];
const TIME_EVENTS = [
EVENT_START_TASK,
EVENT_START_PLAY,
EVENT_STATS_PLAY
EVENT_STATS_PLAY,
];
const ansi = new Ansi();
@ -33,7 +35,7 @@ function JobRenderService ($q, $sce, $window) {
this.init = ({ compile, toggles }) => {
this.parent = null;
this.record = {};
this.el = $(ELEMENT_TBODY);
this.el = $(OUTPUT_ELEMENT_TBODY);
this.hooks = { compile };
this.createToggles = toggles;
@ -67,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 };
}
@ -125,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),
};
@ -165,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

@ -1,11 +1,16 @@
const ELEMENT_CONTAINER = '.at-Stdout-container';
const ELEMENT_TBODY = '#atStdoutResultTable';
const DELAY = 100;
const THRESHOLD = 0.1;
import {
OUTPUT_ELEMENT_CONTAINER,
OUTPUT_ELEMENT_TBODY,
OUTPUT_SCROLL_DELAY,
OUTPUT_SCROLL_THRESHOLD,
} from './constants';
const MAX_THRASH = 20;
function JobScrollService ($q, $timeout) {
this.init = ({ next, previous }) => {
this.el = $(ELEMENT_CONTAINER);
this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(OUTPUT_ELEMENT_CONTAINER);
this.chain = $q.resolve();
this.timer = null;
this.position = {
@ -13,19 +18,43 @@ function JobScrollService ($q, $timeout) {
current: 0
};
this.threshold = {
previous: 0,
current: 0,
};
this.hooks = {
next,
previous,
isAtRest: () => $q.resolve()
onThresholdLeave,
};
this.state = {
hidden: false,
paused: false,
top: true,
locked: false,
hover: false,
running: true,
thrash: 0,
};
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 = () => {
@ -33,77 +62,105 @@ 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);
}
this.timer = $timeout(this.register, DELAY);
this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY);
};
this.register = () => {
this.pause();
const position = this.getScrollPosition();
const viewport = this.getScrollHeight() - this.getViewableHeight();
const current = this.getScrollPosition();
const downward = current > this.position.previous;
const threshold = position / viewport;
const downward = position > this.position.previous;
let promise;
const isBeyondUpperThreshold = threshold < OUTPUT_SCROLL_THRESHOLD;
const isBeyondLowerThreshold = (1 - threshold) < OUTPUT_SCROLL_THRESHOLD;
if (downward && this.isBeyondThreshold(downward, current)) {
promise = this.hooks.next;
} else if (!downward && this.isBeyondThreshold(downward, current)) {
promise = this.hooks.previous;
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 || enteredUpperThreshold) {
transitions.push(this.hooks.onThresholdLeave);
transitions.push(this.hooks.previous);
}
if (!promise) {
this.setScrollPosition(current);
this.isAtRest();
this.resume();
return $q.resolve();
if (leftLowerThreshold) {
transitions.push(this.hooks.onThresholdLeave);
}
return promise()
if (threshold >= 1 || enteredLowerThreshold) {
transitions.push(this.hooks.next);
}
if (!downward) {
transitions.reverse();
}
this.position.current = position;
this.threshold.current = threshold;
transitions.forEach(promise => {
this.chain = this.chain.then(() => promise());
});
return this.chain
.then(() => {
this.setScrollPosition(this.getScrollPosition());
this.isAtRest();
this.resume();
return $q.resolve();
});
};
this.isBeyondThreshold = (downward, current) => {
const height = this.getScrollHeight();
if (downward) {
current += this.getViewableHeight();
if (current >= height || ((height - current) / height) < THRESHOLD) {
return true;
}
} else if (current <= 0 || (current / height) < THRESHOLD) {
return true;
}
return false;
};
/**
* Move scroll position up by one page of visible content.
*/
this.moveUp = () => {
const top = this.getScrollPosition();
const height = this.getViewableHeight();
const position = this.getScrollPosition() - this.getViewableHeight();
this.setScrollPosition(top - height);
this.setScrollPosition(position);
};
/**
* Move scroll position down by one page of visible content.
*/
this.moveDown = () => {
const top = this.getScrollPosition();
const height = this.getViewableHeight();
const position = this.getScrollPosition() + this.getViewableHeight();
this.setScrollPosition(top + height);
this.setScrollPosition(position);
};
this.getScrollHeight = () => this.el[0].scrollHeight;
@ -117,43 +174,37 @@ function JobScrollService ($q, $timeout) {
this.getScrollPosition = () => this.el[0].scrollTop;
this.setScrollPosition = position => {
const viewport = this.getScrollHeight() - this.getViewableHeight();
this.position.previous = this.position.current;
this.threshold.previous = this.position.previous / viewport;
this.position.current = position;
this.el[0].scrollTop = position;
this.isAtRest();
};
this.resetScrollPosition = () => {
this.threshold.previous = 0;
this.position.previous = 0;
this.position.current = 0;
this.el[0].scrollTop = 0;
this.isAtRest();
};
this.scrollToBottom = () => {
this.setScrollPosition(this.getScrollHeight());
};
this.isAtRest = () => {
if (this.position.current === 0 && !this.state.top) {
this.state.top = true;
this.hooks.isAtRest(true);
} else if (this.position.current > 0 && this.state.top) {
this.state.top = false;
this.hooks.isAtRest(false);
}
this.start = () => {
this.state.running = true;
};
this.resume = () => {
this.state.paused = false;
this.stop = () => {
this.unlock();
this.unhide();
this.state.running = false;
};
this.pause = () => {
this.state.paused = true;
};
this.isPaused = () => this.state.paused;
this.lock = () => {
this.state.locked = true;
};
@ -162,22 +213,52 @@ function JobScrollService ($q, $timeout) {
this.state.locked = false;
};
this.pause = () => {
this.state.paused = true;
};
this.resume = () => {
this.state.paused = false;
};
this.hide = () => {
if (!this.state.hidden) {
this.el.css('overflow', 'hidden');
this.state.hidden = true;
if (this.state.hidden) {
return;
}
this.state.hidden = true;
this.el.css('overflow-y', 'hidden');
};
this.unhide = () => {
if (this.state.hidden) {
this.el.css('overflow', 'auto');
this.state.hidden = false;
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 = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
}
JobScrollService.$inject = ['$q', '$timeout'];

View File

@ -1,8 +1,10 @@
const templateUrl = require('~features/output/search.partial.html');
import {
OUTPUT_SEARCH_DOCLINK,
OUTPUT_SEARCH_FIELDS,
OUTPUT_SEARCH_KEY_EXAMPLES,
} from './constants';
const searchKeyExamples = ['host_name:localhost', 'task:set', 'created:>=2000-01-01'];
const searchKeyFields = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play'];
const searchKeyDocLink = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html';
const templateUrl = require('~features/output/search.partial.html');
let $state;
let qs;
@ -50,7 +52,7 @@ function reloadQueryset (queryset, rejection = strings.get('search.REJECT_DEFAUL
const isFilterable = term => {
const field = term[0].split('.')[0].replace(/^-/, '');
return (searchKeyFields.indexOf(field) > -1);
return (OUTPUT_SEARCH_FIELDS.indexOf(field) > -1);
};
function removeSearchTag (index) {
@ -94,9 +96,9 @@ function JobSearchController (_$state_, _qs_, _strings_, { subscribe }) {
vm = this || {};
vm.strings = strings;
vm.examples = searchKeyExamples;
vm.fields = searchKeyFields;
vm.docLink = searchKeyDocLink;
vm.examples = OUTPUT_SEARCH_KEY_EXAMPLES;
vm.fields = OUTPUT_SEARCH_FIELDS;
vm.docLink = OUTPUT_SEARCH_DOCLINK;
vm.relatedFields = [];
vm.clearSearch = clearSearch;

View File

@ -1,97 +1,57 @@
/* eslint camelcase: 0 */
const PAGE_SIZE = 50;
const PAGE_LIMIT = 5;
const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE;
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 }) => {
const { prepend, append, shift, pop, deleteRecord } = storage;
const { getMaxCounter, getRange, getFirst, getLast } = api;
const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage;
const { getRange, getFirst, getLast, getMaxCounter } = api;
this.api = {
getMaxCounter,
getRange,
getFirst,
getLast,
getMaxCounter,
};
this.storage = {
clear,
prepend,
append,
shift,
pop,
getRecord,
deleteRecord,
};
@ -99,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 => {
@ -112,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();
});
@ -128,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();
});
@ -148,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();
@ -177,190 +195,220 @@ 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;
}
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));
}
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();
.then(() => this.api.getRange(next))
.then(events => {
const results = getContinuous(events);
const min = Math.min(...results.map(({ counter }) => counter));
return $q.resolve();
if (min > tail + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popBack(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushFront(results).then(() => $q.resolve(popHeight));
});
});
if (overlap && overlap[0] > 0) {
const pushBackRange = [head - overlap[0], head];
return this.chain;
};
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.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain
.then(() => $q.resolve(popHeight));
.then(() => this.api.getRange(previous))
.then(events => {
const results = getContinuous(events, true);
const max = Math.max(...results.map(({ counter }) => counter));
if (head > max + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popFront(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushBack(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
};
this.getNext = (displacement = 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;
if (newTail - head > EVENT_LIMIT) {
headDisplacement = (newTail - EVENT_LIMIT) - head;
}
return this.move([head + headDisplacement, tail + tailDisplacement]);
return this.chain
.then(() => this.getNext());
};
this.getPrevious = (displacement = PAGE_SIZE) => {
const [head, tail] = this.getRange();
this.getLast = () => {
this.chain = this.chain
.then(() => this.getFrames())
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
const headRoom = head - 1;
const headDisplacement = Math.min(headRoom, displacement);
return this.api.getLast();
})
.then(events => {
const min = Math.min(...events.map(({ counter }) => counter));
const newHead = head - headDisplacement;
if (min <= this.getTailCounter() + 1) {
return this.pushFront(events);
}
let tailDisplacement = 0;
return this.clear()
.then(() => this.pushBack(events));
});
if (tail - newHead > EVENT_LIMIT) {
tailDisplacement = tail - (newHead + EVENT_LIMIT);
}
return this.move([newHead, tail - tailDisplacement]);
return this.chain
.then(() => this.getPrevious());
};
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;
};
this.getFirst = () => this.clear()
.then(() => this.api.getFirst())
.then(events => this.pushFront(events))
.then(() => this.moveTail(PAGE_SIZE));
this.getLast = () => this.clear()
.then(() => this.api.getLast())
.then(events => this.pushBack(events))
.then(() => this.moveHead(-PAGE_SIZE));
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 = () => {
const counter = this.api.getMaxCounter();
const tail = this.getTailCounter();
if (this.buffer.min) {
return this.buffer.min;
}
return Number.isFinite(counter) ? Math.max(tail, counter) : tail;
return this.api.getMaxCounter();
};
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE);
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()];
this.getRecordCount = () => Object.keys(this.records).length;
this.getCapacity = () => EVENT_LIMIT - this.getRecordCount();
this.getRecordCount = () => Object.keys(this.lines).length;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount();
}
SlidingWindowService.$inject = ['$q'];

View File

@ -1,20 +1,22 @@
/* eslint camelcase: 0 */
const JOB_START = 'playbook_on_start';
const JOB_END = 'playbook_on_stats';
const PLAY_START = 'playbook_on_play_start';
const TASK_START = 'playbook_on_task_start';
const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped'];
const COMPLETE = ['successful', 'failed', 'unknown'];
const INCOMPLETE = ['canceled', 'error'];
const UNSUCCESSFUL = ['failed'].concat(INCOMPLETE);
const FINISHED = COMPLETE.concat(INCOMPLETE);
import {
EVENT_START_PLAYBOOK,
EVENT_STATS_PLAY,
EVENT_START_PLAY,
EVENT_START_TASK,
HOST_STATUS_KEYS,
JOB_STATUS_COMPLETE,
JOB_STATUS_INCOMPLETE,
JOB_STATUS_UNSUCCESSFUL,
JOB_STATUS_FINISHED,
} from './constants';
function JobStatusService (moment, message) {
this.dispatch = () => message.dispatch('status', this.state);
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');
@ -43,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 {
@ -50,23 +60,22 @@ 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 => {
if (UNSUCCESSFUL.includes(status)) {
if (JOB_STATUS_UNSUCCESSFUL.includes(status)) {
return { failures: 1 };
}
if (COMPLETE.includes(status)) {
if (JOB_STATUS_COMPLETE.includes(status)) {
return { ok: 1 };
}
@ -92,7 +101,7 @@ function JobStatusService (moment, message) {
let changed = false;
if (!this.active && !(data.event === JOB_END)) {
if (!this.active && !(data.event === EVENT_STATS_PLAY)) {
this.active = true;
this.setJobStatus('running');
changed = true;
@ -105,22 +114,22 @@ function JobStatusService (moment, message) {
changed = true;
}
if (data.event === JOB_START) {
if (data.event === EVENT_START_PLAYBOOK) {
this.setStarted(this.state.started || data.created);
changed = true;
}
if (data.event === PLAY_START) {
if (data.event === EVENT_START_PLAY) {
this.state.counts.plays++;
changed = true;
}
if (data.event === TASK_START) {
if (data.event === EVENT_START_TASK) {
this.state.counts.tasks++;
changed = true;
}
if (data.event === JOB_END) {
if (data.event === EVENT_STATS_PLAY) {
this.setStatsEvent(data);
changed = true;
}
@ -193,17 +202,20 @@ function JobStatusService (moment, message) {
this.setJobStatus = status => {
const isExpectingStats = this.isExpectingStatsEvent();
const isIncomplete = INCOMPLETE.includes(status);
const isFinished = FINISHED.includes(status);
const isAlreadyFinished = FINISHED.includes(this.state.status);
const isIncomplete = JOB_STATUS_INCOMPLETE.includes(status);
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'));
@ -216,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();
};
@ -233,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;
};
@ -266,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

@ -1,7 +1,9 @@
/* eslint camelcase: 0 */
const PAGE_SIZE = 50;
const MAX_LAG = 120;
const JOB_END = 'playbook_on_stats';
import {
EVENT_STATS_PLAY,
OUTPUT_MAX_LAG,
OUTPUT_PAGE_SIZE,
} from './constants';
function OutputStream ($q) {
this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => {
@ -22,13 +24,13 @@ function OutputStream ($q) {
this.state = {
ending: false,
ended: false
ended: false,
};
this.lag = 0;
this.chain = $q.resolve();
this.factors = this.calcFactors(PAGE_SIZE);
this.factors = this.calcFactors(OUTPUT_PAGE_SIZE);
this.setFramesPerRender();
};
@ -47,7 +49,7 @@ function OutputStream ($q) {
};
this.setFramesPerRender = () => {
const index = Math.floor((this.lag / MAX_LAG) * this.factors.length);
const index = Math.floor((this.lag / OUTPUT_MAX_LAG) * this.factors.length);
const boundedIndex = Math.min(this.factors.length - 1, index);
this.framesPerRender = this.factors[boundedIndex];
@ -96,7 +98,7 @@ function OutputStream ($q) {
this.chain = this.chain
.then(() => {
if (data.event === JOB_END) {
if (data.event === EVENT_STATS_PLAY) {
this.state.ending = true;
this.counters.final = data.counter;
}
@ -104,7 +106,7 @@ function OutputStream ($q) {
const [minReady, maxReady] = this.updateCounterState(data);
const count = this.hooks.bufferAdd(data);
if (count % PAGE_SIZE === 0) {
if (count % OUTPUT_PAGE_SIZE === 0) {
this.setFramesPerRender();
}
@ -158,6 +160,8 @@ function OutputStream ($q) {
this.counters.ready.length = 0;
return $q.resolve();
});
this.getMaxCounter = () => this.counters.max;
}
OutputStream.$inject = ['$q'];