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
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 { &-menuIconGroup {
& > p { & > p {
margin: 0; margin: 0;

View File

@@ -1,10 +1,12 @@
const API_PAGE_SIZE = 200; import {
const PAGE_SIZE = 50; API_MAX_PAGE_SIZE,
const ORDER_BY = 'counter'; OUTPUT_ORDER_BY,
OUTPUT_PAGE_SIZE,
} from './constants';
const BASE_PARAMS = { const BASE_PARAMS = {
page_size: PAGE_SIZE, page_size: OUTPUT_PAGE_SIZE,
order_by: ORDER_BY, order_by: OUTPUT_ORDER_BY,
}; };
const merge = (...objs) => _.merge({}, ...objs); const merge = (...objs) => _.merge({}, ...objs);
@@ -18,12 +20,6 @@ function JobEventsApiService ($http, $q) {
this.cache = {}; this.cache = {};
}; };
this.clearCache = () => {
Object.keys(this.cache).forEach(key => {
delete this.cache[key];
});
};
this.fetch = () => this.getLast() this.fetch = () => this.getLast()
.then(results => { .then(results => {
this.cache.last = results; this.cache.last = results;
@@ -31,20 +27,31 @@ function JobEventsApiService ($http, $q) {
return this; 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 = () => { this.getFirst = () => {
const page = 1; const params = merge(this.params, { page: 1 });
const params = merge(this.params, { page });
return $http.get(this.endpoint, { params }) return $http.get(this.endpoint, { params })
.then(({ data }) => { .then(({ data }) => {
const { results, count } = data; const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
this.state.count = count; this.state.count = count;
this.pushMaxCounter(results);
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
return results; return results;
}); });
@@ -60,13 +67,9 @@ function JobEventsApiService ($http, $q) {
return $http.get(this.endpoint, { params }) return $http.get(this.endpoint, { params })
.then(({ data }) => { .then(({ data }) => {
const { results, count } = data; const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
this.state.count = count; this.state.count = count;
this.pushMaxCounter(results);
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
return results; return results;
}); });
@@ -77,17 +80,16 @@ function JobEventsApiService ($http, $q) {
return $q.resolve(this.cache.last); 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 }) return $http.get(this.endpoint, { params })
.then(({ data }) => { .then(({ data }) => {
const { results, count } = data; const { results, count } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
let rotated = results; let rotated = results;
if (count > PAGE_SIZE) { if (count > OUTPUT_PAGE_SIZE) {
rotated = results.splice(count % PAGE_SIZE); rotated = results.splice(count % OUTPUT_PAGE_SIZE);
if (results.length > 0) { if (results.length > 0) {
rotated = results; rotated = results;
@@ -95,10 +97,7 @@ function JobEventsApiService ($http, $q) {
} }
this.state.count = count; this.state.count = count;
this.pushMaxCounter(results);
if (maxCounter > this.state.maxCounter) {
this.state.maxCounter = maxCounter;
}
return rotated; return rotated;
}); });
@@ -110,24 +109,26 @@ 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_PAGE_SIZE; params.page_size = API_MAX_PAGE_SIZE;
return $http.get(this.endpoint, { params }) return $http.get(this.endpoint, { params })
.then(({ data }) => { .then(({ data }) => {
const { results } = data; const { results } = data;
const maxCounter = Math.max(...results.map(({ counter }) => counter));
if (maxCounter > this.state.maxCounter) { this.pushMaxCounter(results);
this.state.maxCounter = maxCounter;
}
return 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; 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 */ /* eslint camelcase: 0 */
const EVENT_START_TASK = 'playbook_on_task_start'; import {
const EVENT_START_PLAY = 'playbook_on_play_start'; EVENT_START_PLAY,
EVENT_START_TASK,
OUTPUT_PAGE_SIZE,
} from './constants';
let $compile; let $compile;
let $q; let $q;
@@ -20,9 +23,6 @@ const bufferState = [0, 0]; // [length, count]
const listeners = []; const listeners = [];
const rx = []; const rx = [];
let following = false;
let attach = true;
function bufferInit () { function bufferInit () {
rx.length = 0; rx.length = 0;
@@ -55,57 +55,109 @@ function bufferEmpty (min, max) {
return removed; return removed;
} }
let lockFrames;
function onFrames (events) { function onFrames (events) {
if (!following) { if (lockFrames) {
const minCounter = Math.min(...events.map(({ counter }) => counter)); events.forEach(bufferAdd);
// attachment range return $q.resolve();
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();
} }
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) if (!isAttached) {
.then(() => slide.pushFront(events)) stopFollowing();
.then(() => { return $q.resolve();
scroll.setScrollPosition(scroll.getScrollHeight()); }
return $q.resolve(); if (!vm.isFollowing && canStartFollowing()) {
}); startFollowing();
} }
if (!vm.isFollowing && popCount > 0) {
return $q.resolve();
}
function first () {
unfollow();
scroll.pause(); scroll.pause();
return slide.getFirst() if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.popBack(popCount)
.then(() => { .then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.pushFront(events);
})
.then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
scroll.resume(); scroll.resume();
return $q.resolve(); return $q.resolve();
}); });
} }
function next () { function first () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause(); 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() return slide.getNext()
.finally(() => scroll.resume()); .finally(() => {
scroll.resume();
lockFrames = false;
});
} }
function previous () { function previous () {
unfollow(); if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause(); scroll.pause();
lockFrames = true;
stopFollowing();
const initialPosition = scroll.getScrollPosition(); const initialPosition = scroll.getScrollPosition();
@@ -116,51 +168,101 @@ function previous () {
return $q.resolve(); return $q.resolve();
}) })
.finally(() => scroll.resume()); .finally(() => {
scroll.resume();
lockFrames = false;
});
} }
function last () { function last () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause(); scroll.pause();
lockFrames = true;
return slide.getLast() return slide.getLast()
.then(() => { .then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1); stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
scroll.setScrollPosition(scroll.getScrollHeight()); scroll.scrollToBottom();
attach = true;
scroll.resume();
return $q.resolve(); 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 () { function down () {
scroll.moveDown(); scroll.moveDown();
} }
function up () { function up () {
if (following) { scroll.moveUp();
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();
} }
function togglePanelExpand () { function togglePanelExpand () {
@@ -235,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;
} }
@@ -252,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) {
@@ -316,37 +454,62 @@ function OutputIndexController (
vm.togglePanelExpand = togglePanelExpand; vm.togglePanelExpand = togglePanelExpand;
// Stdout Navigation // Stdout Navigation
vm.menu = { last, first, down, up }; vm.menu = { last: menuLast, first, down, up };
vm.isMenuExpanded = true; vm.isMenuExpanded = true;
vm.isFollowing = following; vm.isFollowing = 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); slide.init(render, resource.events, scroll);
render.init({ compile, toggles: vm.toggleLineEnabled }); 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({ stream.init({
bufferAdd, bufferAdd,
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';
@@ -18,16 +19,15 @@ import SearchComponent from '~features/output/search.component';
import StatsComponent from '~features/output/stats.component'; import StatsComponent from '~features/output/stats.component';
import HostEvent from './host-event/index'; 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 MODULE_NAME = 'at.features.output';
const Template = require('~features/output/index.view.html');
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/';
function resolveResource ( function resolveResource (
$state, $state,
@@ -42,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;
@@ -80,23 +78,16 @@ function resolveResource (
} }
const params = { const params = {
page_size: PAGE_SIZE, page_size: OUTPUT_PAGE_SIZE,
order_by: ORDER_BY, order_by: OUTPUT_ORDER_BY,
};
const config = {
params,
pageCache: PAGE_CACHE,
pageLimit: PAGE_LIMIT,
}; };
if (job_event_search) { // eslint-disable-line camelcase if (job_event_search) { // eslint-disable-line camelcase
const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search));
Object.assign(params, query);
Object.assign(config.params, query);
} }
Events.init(`${API_ROOT}${related}`, config.params); Events.init(`${API_ROOT}${related}`, params);
Wait('start'); Wait('start');
const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()]) const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()])

View File

@@ -7,56 +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 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> </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> </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

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

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

@@ -1,11 +1,16 @@
const ELEMENT_CONTAINER = '.at-Stdout-container'; import {
const ELEMENT_TBODY = '#atStdoutResultTable'; OUTPUT_ELEMENT_CONTAINER,
const DELAY = 100; OUTPUT_ELEMENT_TBODY,
const THRESHOLD = 0.1; OUTPUT_SCROLL_DELAY,
OUTPUT_SCROLL_THRESHOLD,
} from './constants';
const MAX_THRASH = 20;
function JobScrollService ($q, $timeout) { function JobScrollService ($q, $timeout) {
this.init = ({ next, previous }) => { this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(ELEMENT_CONTAINER); this.el = $(OUTPUT_ELEMENT_CONTAINER);
this.chain = $q.resolve();
this.timer = null; this.timer = null;
this.position = { this.position = {
@@ -13,19 +18,43 @@ function JobScrollService ($q, $timeout) {
current: 0 current: 0
}; };
this.threshold = {
previous: 0,
current: 0,
};
this.hooks = { this.hooks = {
next, next,
previous, previous,
isAtRest: () => $q.resolve() onThresholdLeave,
}; };
this.state = { this.state = {
hidden: false,
paused: false, paused: false,
top: true, locked: false,
hover: false,
running: true,
thrash: 0,
}; };
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 = () => {
@@ -33,77 +62,105 @@ 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);
} }
this.timer = $timeout(this.register, DELAY); this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY);
}; };
this.register = () => { this.register = () => {
this.pause(); const position = this.getScrollPosition();
const viewport = this.getScrollHeight() - this.getViewableHeight();
const current = this.getScrollPosition(); const threshold = position / viewport;
const downward = current > this.position.previous; 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)) { const wasBeyondUpperThreshold = this.threshold.previous < OUTPUT_SCROLL_THRESHOLD;
promise = this.hooks.next; const wasBeyondLowerThreshold = (1 - this.threshold.previous) < OUTPUT_SCROLL_THRESHOLD;
} else if (!downward && this.isBeyondThreshold(downward, current)) {
promise = this.hooks.previous; 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) { if (leftLowerThreshold) {
this.setScrollPosition(current); transitions.push(this.hooks.onThresholdLeave);
this.isAtRest();
this.resume();
return $q.resolve();
} }
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(() => { .then(() => {
this.setScrollPosition(this.getScrollPosition()); 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. * Move scroll position up by one page of visible content.
*/ */
this.moveUp = () => { this.moveUp = () => {
const top = this.getScrollPosition(); const position = this.getScrollPosition() - this.getViewableHeight();
const height = this.getViewableHeight();
this.setScrollPosition(top - height); this.setScrollPosition(position);
}; };
/** /**
* Move scroll position down by one page of visible content. * Move scroll position down by one page of visible content.
*/ */
this.moveDown = () => { this.moveDown = () => {
const top = this.getScrollPosition(); const position = this.getScrollPosition() + this.getViewableHeight();
const height = this.getViewableHeight();
this.setScrollPosition(top + height); this.setScrollPosition(position);
}; };
this.getScrollHeight = () => this.el[0].scrollHeight; this.getScrollHeight = () => this.el[0].scrollHeight;
@@ -117,43 +174,37 @@ function JobScrollService ($q, $timeout) {
this.getScrollPosition = () => this.el[0].scrollTop; this.getScrollPosition = () => this.el[0].scrollTop;
this.setScrollPosition = position => { this.setScrollPosition = position => {
const viewport = this.getScrollHeight() - this.getViewableHeight();
this.position.previous = this.position.current; this.position.previous = this.position.current;
this.threshold.previous = this.position.previous / viewport;
this.position.current = position; this.position.current = position;
this.el[0].scrollTop = position; this.el[0].scrollTop = position;
this.isAtRest();
}; };
this.resetScrollPosition = () => { this.resetScrollPosition = () => {
this.threshold.previous = 0;
this.position.previous = 0; this.position.previous = 0;
this.position.current = 0; this.position.current = 0;
this.el[0].scrollTop = 0; this.el[0].scrollTop = 0;
this.isAtRest();
}; };
this.scrollToBottom = () => { this.scrollToBottom = () => {
this.setScrollPosition(this.getScrollHeight()); this.setScrollPosition(this.getScrollHeight());
}; };
this.isAtRest = () => { this.start = () => {
if (this.position.current === 0 && !this.state.top) { this.state.running = true;
this.state.top = true;
this.hooks.isAtRest(true);
} else if (this.position.current > 0 && this.state.top) {
this.state.top = false;
this.hooks.isAtRest(false);
}
}; };
this.resume = () => { this.stop = () => {
this.state.paused = false; this.unlock();
this.unhide();
this.state.running = false;
}; };
this.pause = () => {
this.state.paused = true;
};
this.isPaused = () => this.state.paused;
this.lock = () => { this.lock = () => {
this.state.locked = true; this.state.locked = true;
}; };
@@ -162,22 +213,52 @@ function JobScrollService ($q, $timeout) {
this.state.locked = false; this.state.locked = false;
}; };
this.pause = () => {
this.state.paused = true;
};
this.resume = () => {
this.state.paused = false;
};
this.hide = () => { this.hide = () => {
if (!this.state.hidden) { if (this.state.hidden) {
this.el.css('overflow', 'hidden'); return;
this.state.hidden = true;
} }
this.state.hidden = true;
this.el.css('overflow-y', 'hidden');
}; };
this.unhide = () => { this.unhide = () => {
if (this.state.hidden) { if (!this.state.hidden) {
this.el.css('overflow', 'auto'); return;
this.state.hidden = false;
} }
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.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']; 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 templateUrl = require('~features/output/search.partial.html');
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';
let $state; let $state;
let qs; let qs;
@@ -50,7 +52,7 @@ function reloadQueryset (queryset, rejection = strings.get('search.REJECT_DEFAUL
const isFilterable = term => { const isFilterable = term => {
const field = term[0].split('.')[0].replace(/^-/, ''); const field = term[0].split('.')[0].replace(/^-/, '');
return (searchKeyFields.indexOf(field) > -1); return (OUTPUT_SEARCH_FIELDS.indexOf(field) > -1);
}; };
function removeSearchTag (index) { function removeSearchTag (index) {
@@ -94,9 +96,9 @@ function JobSearchController (_$state_, _qs_, _strings_, { subscribe }) {
vm = this || {}; vm = this || {};
vm.strings = strings; vm.strings = strings;
vm.examples = searchKeyExamples; vm.examples = OUTPUT_SEARCH_KEY_EXAMPLES;
vm.fields = searchKeyFields; vm.fields = OUTPUT_SEARCH_FIELDS;
vm.docLink = searchKeyDocLink; vm.docLink = OUTPUT_SEARCH_DOCLINK;
vm.relatedFields = []; vm.relatedFields = [];
vm.clearSearch = clearSearch; vm.clearSearch = clearSearch;

View File

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

View File

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

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