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