From 80d6b0167c38ddec03d3c91f0705c173dd863a4e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 3 Aug 2018 15:46:04 -0400 Subject: [PATCH] implement output follow-scroll behavior --- awx/ui/client/features/output/_index.less | 21 - .../features/output/api.events.service.js | 5 + .../features/output/index.controller.js | 307 +++++++---- awx/ui/client/features/output/index.js | 5 +- awx/ui/client/features/output/index.view.html | 77 +-- .../client/features/output/output.strings.js | 7 +- awx/ui/client/features/output/page.service.js | 4 +- .../client/features/output/render.service.js | 7 + .../client/features/output/scroll.service.js | 142 ++++- .../client/features/output/slide.service.js | 484 ++++++++++-------- .../client/features/output/status.service.js | 46 +- .../client/features/output/stream.service.js | 2 +- 12 files changed, 697 insertions(+), 410 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(); - } - - attached = true; - } - - if (vm.isInFollowMode) { - vm.isFollowing = true; - } - - const capacity = slide.getCapacity(); - - if (capacity <= 0 && !isOnLastPage) { - attached = false; + events = slide.pushFrames(events); + const popCount = events.length - slide.getCapacity(); + const isAttached = events.length > 0; + if (!isAttached) { + stopFollowing(); return $q.resolve(); } - return slide.popBack(events.length - capacity) - .then(() => slide.pushFront(events)) + if (!vm.isFollowing && canStartFollowing()) { + startFollowing(); + } + + if (!vm.isFollowing && popCount > 0) { + return $q.resolve(); + } + + scroll.pause(); + + if (vm.isFollowing) { + scroll.scrollToBottom(); + } + + return slide.popBack(popCount) .then(() => { - if (vm.isFollowing && scroll.isBeyondLowerThreshold()) { + if (vm.isFollowing) { scroll.scrollToBottom(); } + return slide.pushFront(events); + }) + .then(() => { + if (vm.isFollowing) { + scroll.scrollToBottom(); + } + + scroll.resume(); + return $q.resolve(); }); } function first () { + if (scroll.isPaused()) { + return $q.resolve(); + } + scroll.pause(); - unfollow(); + lockFrames = true; - attached = false; - noframes = true; - isOnLastPage = false; + stopFollowing(); - slide.getFirst() + return slide.getFirst() .then(() => { + scroll.resetScrollPosition(); + }) + .finally(() => { scroll.resume(); - noframes = false; - - return $q.resolve(); + lockFrames = false; }); } function next () { if (vm.isFollowing) { + scroll.scrollToBottom(); + + return $q.resolve(); + } + + if (scroll.isPaused()) { + return $q.resolve(); + } + + if (slide.getTailCounter() >= slide.getMaxCounter()) { return $q.resolve(); } scroll.pause(); + lockFrames = true; return slide.getNext() - .then(() => { - isOnLastPage = slide.isOnLastPage(); - if (isOnLastPage) { - stream.setMissingCounterThreshold(slide.getTailCounter() + 1); - if (scroll.isBeyondLowerThreshold()) { - scroll.scrollToBottom(); - follow(); - } - } - }) - .finally(() => scroll.resume()); + .finally(() => { + scroll.resume(); + lockFrames = false; + }); } function previous () { + if (scroll.isPaused()) { + return $q.resolve(); + } + scroll.pause(); + lockFrames = true; + + stopFollowing(); const initialPosition = scroll.getScrollPosition(); - isOnLastPage = false; return slide.getPrevious() .then(popHeight => { @@ -147,17 +168,87 @@ function previous () { return $q.resolve(); }) - .finally(() => scroll.resume()); + .finally(() => { + scroll.resume(); + lockFrames = false; + }); +} + +function last () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + lockFrames = true; + + return slide.getLast() + .then(() => { + stream.setMissingCounterThreshold(slide.getTailCounter() + 1); + scroll.scrollToBottom(); + + return $q.resolve(); + }) + .finally(() => { + scroll.resume(); + lockFrames = false; + }); +} + +let followOnce; +let lockFollow; +function canStartFollowing () { + if (lockFollow) { + return false; + } + + if (slide.isOnLastPage() && scroll.isBeyondLowerThreshold()) { + followOnce = false; + + return true; + } + + if (followOnce && // one-time activation from top of first page + scroll.isBeyondUpperThreshold() && + slide.getHeadCounter() === 1 && + slide.getTailCounter() >= OUTPUT_PAGE_SIZE) { + followOnce = false; + + return true; + } + + return false; +} + +function startFollowing () { + if (vm.isFollowing) { + return; + } + + vm.isFollowing = true; + vm.followTooltip = vm.strings.get('tooltips.MENU_FOLLOWING'); +} + +function stopFollowing () { + if (!vm.isFollowing) { + return; + } + + vm.isFollowing = false; + vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); } function menuLast () { if (vm.isFollowing) { - unfollow(); + lockFollow = true; + stopFollowing(); return $q.resolve(); } - if (isOnLastPage) { + lockFollow = false; + + if (slide.isOnLastPage()) { scroll.scrollToBottom(); return $q.resolve(); @@ -166,22 +257,6 @@ function menuLast () { return last(); } -function last () { - scroll.pause(); - - return slide.getLast() - .then(() => { - stream.setMissingCounterThreshold(slide.getTailCounter() + 1); - scroll.setScrollPosition(scroll.getScrollHeight()); - - isOnLastPage = true; - follow(); - scroll.resume(); - - return $q.resolve(); - }); -} - function down () { scroll.moveDown(); } @@ -190,20 +265,6 @@ function up () { scroll.moveUp(); } -function follow () { - isOnLastPage = slide.isOnLastPage(); - - if (resource.model.get('event_processing_finished')) return; - if (!isOnLastPage) return; - - vm.isInFollowMode = true; -} - -function unfollow () { - vm.isInFollowMode = false; - vm.isFollowing = false; -} - function togglePanelExpand () { vm.isPanelExpanded = !vm.isPanelExpanded; } @@ -276,7 +337,10 @@ function showHostDetails (id, uuid) { $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); } +let streaming; function stopListening () { + streaming = null; + listeners.forEach(deregister => deregister()); listeners.length = 0; } @@ -293,13 +357,46 @@ function startListening () { listeners.push($scope.$on(resource.ws.summary, (scope, data) => handleSummaryEvent(data))); } -function handleStatusEvent (data) { - status.pushStatusEvent(data); +function handleJobEvent (data) { + streaming = streaming || resource.events + .getRange([Math.max(1, data.counter - 50), data.counter + 50]) + .then(results => { + results = results.concat(data); + + const counters = results.map(({ counter }) => counter); + const min = Math.min(...counters); + const max = Math.max(...counters); + + const missing = []; + for (let i = min; i <= max; i++) { + if (counters.indexOf(i) < 0) { + missing.push(i); + } + } + + if (missing.length > 0) { + const maxMissing = Math.max(...missing); + results = results.filter(({ counter }) => counter > maxMissing); + } + + stream.setMissingCounterThreshold(max + 1); + results.forEach(item => { + stream.pushJobEvent(item); + status.pushJobEvent(item); + }); + + return $q.resolve(); + }); + + streaming + .then(() => { + stream.pushJobEvent(data); + status.pushJobEvent(data); + }); } -function handleJobEvent (data) { - stream.pushJobEvent(data); - status.pushJobEvent(data); +function handleStatusEvent (data) { + status.pushStatusEvent(data); } function handleSummaryEvent (data) { @@ -315,13 +412,6 @@ function reloadState (params) { return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' }); } -function getMaxCounter () { - const apiMax = resource.events.getMaxCounter(); - const wsMax = stream.getMaxCounter(); - - return Math.max(apiMax, wsMax); -} - function OutputIndexController ( _$compile_, _$q_, @@ -367,28 +457,27 @@ function OutputIndexController ( vm.menu = { last: menuLast, first, down, up }; vm.isMenuExpanded = true; vm.isFollowing = false; - vm.isInFollowMode = false; vm.toggleMenuExpand = toggleMenuExpand; vm.toggleLineExpand = toggleLineExpand; vm.showHostDetails = showHostDetails; vm.toggleLineEnabled = resource.model.get('type') === 'job'; + vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); render.requestAnimationFrame(() => { bufferInit(); status.init(resource); - slide.init(render, resource.events, scroll, { getMaxCounter }); + slide.init(render, resource.events, scroll); render.init({ compile, toggles: vm.toggleLineEnabled }); scroll.init({ next, previous, - onLeaveLower () { - unfollow(); - return $q.resolve(); - }, - onEnterLower () { - follow(); + onThresholdLeave () { + followOnce = false; + lockFollow = false; + stopFollowing(); + return $q.resolve(); }, }); @@ -398,15 +487,29 @@ function OutputIndexController ( bufferEmpty, onFrames, onStop () { + lockFollow = true; + stopFollowing(); stopListening(); status.updateStats(); status.dispatch(); - unfollow(); + status.sync(); + scroll.stop(); } }); - startListening(); - status.subscribe(data => { vm.status = data.status; }); + if (resource.model.get('event_processing_finished')) { + followOnce = false; + lockFollow = true; + lockFrames = true; + stopListening(); + } else { + followOnce = true; + lockFollow = false; + lockFrames = false; + resource.events.clearCache(); + status.subscribe(data => { vm.status = data.status; }); + startListening(); + } return last(); }); 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 @@ -
-
- - {{ vm.title }} -
- - - - -
-
- +
+
+ + {{ vm.title }}
-
+ + + + +
+
+ +
+
+ ng-class="{ 'at-Stdout-menuIcon--active': vm.isFollowing }" + data-placement="top" + data-trigger="hover" + data-tip-watch="vm.followTooltip" + aw-tool-tip="{{ vm.followTooltip }}"> + +
+
+ +
+
+ +
+
+ +
+
-
- +
+
+
+
-
- -
-
- -
- -
- -
-
-
-
-
-
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]); +function getContinuous (events, reverse = false) { + const counters = events.map(({ counter }) => counter); - return (range[1] - range[0]) + (other[1] - other[0]) >= span; -} + const min = Math.min(...counters); + const max = Math.max(...counters); -/** - * Get an array that describes the overlap of two ranges. - * - * @arg {Array} range - A [low, high] range array. - * @arg {Array} other - A [low, high] range array to be compared with the first. - * - * @returns {(Array|Boolean)} - Returns false if the ranges aren't overlapping. - * For overlapping ranges, a length-2 array describing the nature of the overlap - * is returned. The overlap array describes the position of the second range in - * terms of how many steps inward (negative) or outward (positive) its sides are - * relative to the first range. - * - * ++45678 - * 234---- => getOverlapArray([4, 8], [2, 4]) = [2, -4] - * - * 45678 - * 45--- => getOverlapArray([4, 8], [4, 5]) = [0, -3] - * - * 45678 - * -56-- => getOverlapArray([4, 8], [5, 6]) = [-1, -2] - * - * 45678 - * --678 => getOverlapArray([4, 8], [6, 8]) = [-2, 0] - * - * 456++ - * --678 => getOverlapArray([4, 6], [6, 8]) = [-2, 2] - * - * +++456++ - * 12345678 => getOverlapArray([4, 6], [1, 8]) = [3, 2] - ^ - * 12345678 - * ---456-- => getOverlapArray([1, 8], [4, 6]) = [-3, -2] - */ -function getOverlapArray (range, other) { - if (!checkRangeOverlap(range, other)) { - return false; + const missing = []; + for (let i = min; i <= max; i++) { + if (counters.indexOf(i) < 0) { + missing.push(i); + } } - return [range[0] - other[0], other[1] - range[1]]; -} + if (missing.length === 0) { + return events; + } -/** - * Apply a minimum and maximum boundary to a range. - * - * @arg {Array} range - A [low, high] range array. - * @arg {Array} other - A [low, high] range array to be applied as a boundary. - * - * @returns {(Array)} - Returns a new range array by applying the second range - * as a boundary to the first. - * - * getBoundedRange([2, 6], [2, 8]) = [2, 6] - * getBoundedRange([1, 9], [2, 8]) = [2, 8] - * getBoundedRange([4, 9], [2, 8]) = [4, 8] - */ -function getBoundedRange (range, other) { - return [Math.max(range[0], other[0]), Math.min(range[1], other[1])]; + if (reverse) { + const threshold = Math.max(...missing); + + return events.filter(({ counter }) => counter > threshold); + } + + const threshold = Math.min(...missing); + + return events.filter(({ counter }) => counter < threshold); } function SlidingWindowService ($q) { - this.init = (storage, api, { getScrollHeight }, { getMaxCounter }) => { - const { prepend, append, shift, pop, deleteRecord } = storage; - const { getRange, getFirst, getLast } = api; + this.init = (storage, api, { getScrollHeight }) => { + const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage; + const { getRange, getFirst, getLast, getMaxCounter } = api; this.api = { getRange, @@ -89,10 +46,12 @@ function SlidingWindowService ($q) { }; this.storage = { + clear, prepend, append, shift, pop, + getRecord, deleteRecord, }; @@ -100,11 +59,79 @@ function SlidingWindowService ($q) { getScrollHeight, }; - this.records = {}; + this.lines = {}; this.uuids = {}; this.chain = $q.resolve(); - api.clearCache(); + this.state = { head: null, tail: null }; + this.cache = { first: null }; + + this.buffer = { + events: [], + min: 0, + max: 0, + count: 0, + }; + }; + + this.getBoundedRange = range => { + const bounds = [1, this.getMaxCounter()]; + + return [Math.max(range[0], bounds[0]), Math.min(range[1], bounds[1])]; + }; + + this.getNextRange = displacement => { + const tail = this.getTailCounter(); + + return this.getBoundedRange([tail + 1, tail + 1 + displacement]); + }; + + this.getPreviousRange = displacement => { + const head = this.getHeadCounter(); + + return this.getBoundedRange([head - 1 - displacement, head - 1]); + }; + + this.createRecord = ({ counter, uuid, start_line, end_line }) => { + this.lines[counter] = end_line - start_line; + this.uuids[counter] = uuid; + + if (this.state.tail === null) { + this.state.tail = counter; + } + + if (counter > this.state.tail) { + this.state.tail = counter; + } + + if (this.state.head === null) { + this.state.head = counter; + } + + if (counter < this.state.head) { + this.state.head = counter; + } + }; + + this.deleteRecord = counter => { + this.storage.deleteRecord(this.uuids[counter]); + + delete this.uuids[counter]; + delete this.lines[counter]; + }; + + this.getLineCount = counter => { + const record = this.storage.getRecord(counter); + + if (record && record.lineCount) { + return record.lineCount; + } + + if (this.lines[counter]) { + return this.lines[counter]; + } + + return 0; }; this.pushFront = events => { @@ -113,10 +140,7 @@ function SlidingWindowService ($q) { return this.storage.append(newEvents) .then(() => { - newEvents.forEach(({ counter, start_line, end_line, uuid }) => { - this.records[counter] = { start_line, end_line }; - this.uuids[counter] = uuid; - }); + newEvents.forEach(event => this.createRecord(event)); return $q.resolve(); }); @@ -129,10 +153,7 @@ function SlidingWindowService ($q) { return this.storage.prepend(newEvents) .then(() => { - newEvents.forEach(({ counter, start_line, end_line, uuid }) => { - this.records[counter] = { start_line, end_line }; - this.uuids[counter] = uuid; - }); + newEvents.forEach(event => this.createRecord(event)); return $q.resolve(); }); @@ -149,18 +170,14 @@ function SlidingWindowService ($q) { let lines = 0; for (let i = max; i >= min; --i) { - if (this.records[i]) { - lines += (this.records[i].end_line - this.records[i].start_line); - } + lines += this.getLineCount(i); } return this.storage.pop(lines) .then(() => { for (let i = max; i >= min; --i) { - delete this.records[i]; - - this.storage.deleteRecord(this.uuids[i]); - delete this.uuids[i]; + this.deleteRecord(i); + this.state.tail--; } return $q.resolve(); @@ -178,184 +195,219 @@ function SlidingWindowService ($q) { let lines = 0; for (let i = min; i <= max; ++i) { - if (this.records[i]) { - lines += (this.records[i].end_line - this.records[i].start_line); - } + lines += this.getLineCount(i); } return this.storage.shift(lines) .then(() => { for (let i = min; i <= max; ++i) { - delete this.records[i]; - - this.storage.deleteRecord(this.uuids[i]); - delete this.uuids[i]; + this.deleteRecord(i); + this.state.head++; } return $q.resolve(); }); }; - this.move = ([low, high]) => { - const bounds = [1, this.getMaxCounter()]; - const [newHead, newTail] = getBoundedRange([low, high], bounds); + this.clear = () => this.storage.clear() + .then(() => { + const [head, tail] = this.getRange(); - let popHeight = this.hooks.getScrollHeight(); + for (let i = head; i <= tail; ++i) { + this.deleteRecord(i); + } - if (newHead > newTail) { - this.chain = this.chain - .then(() => $q.resolve(popHeight)); + this.state.head = null; + this.state.tail = null; - return this.chain; - } - - if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) { - this.chain = this.chain - .then(() => $q.resolve(popHeight)); - - return this.chain; - } - - const [head, tail] = this.getRange(); - const overlap = getOverlapArray([head, tail], [newHead, newTail]); - - if (!overlap) { - this.chain = this.chain - .then(() => this.clear()) - .then(() => this.api.getRange([newHead, newTail])) - .then(events => this.pushFront(events)); - } - - if (overlap && overlap[0] < 0) { - const popBackCount = Math.abs(overlap[0]); - - this.chain = this.chain.then(() => this.popBack(popBackCount)); - } - - if (overlap && overlap[1] < 0) { - const popFrontCount = Math.abs(overlap[1]); - - this.chain = this.chain.then(() => this.popFront(popFrontCount)); - } - - this.chain = this.chain - .then(() => { - popHeight = this.hooks.getScrollHeight(); - - return $q.resolve(); - }); - - if (overlap && overlap[0] > 0) { - const pushBackRange = [head - overlap[0], head]; - - this.chain = this.chain - .then(() => this.api.getRange(pushBackRange)) - .then(events => this.pushBack(events)); - } - - if (overlap && overlap[1] > 0) { - const pushFrontRange = [tail, tail + overlap[1]]; - - this.chain = this.chain - .then(() => this.api.getRange(pushFrontRange)) - .then(events => this.pushFront(events)); - } - - this.chain = this.chain - .then(() => $q.resolve(popHeight)); - - return this.chain; - }; + return $q.resolve(); + }); this.getNext = (displacement = OUTPUT_PAGE_SIZE) => { + const next = this.getNextRange(displacement); const [head, tail] = this.getRange(); - const tailRoom = this.getMaxCounter() - tail; - const tailDisplacement = Math.min(tailRoom, displacement); + this.chain = this.chain + .then(() => this.api.getRange(next)) + .then(events => { + const results = getContinuous(events); + const min = Math.min(...results.map(({ counter }) => counter)); - const newTail = tail + tailDisplacement; + if (min > tail + 1) { + return $q.resolve([]); + } - let headDisplacement = 0; + return $q.resolve(results); + }) + .then(results => { + const count = (tail - head + results.length); + const excess = count - OUTPUT_EVENT_LIMIT; - if (newTail - head > OUTPUT_EVENT_LIMIT) { - headDisplacement = (newTail - OUTPUT_EVENT_LIMIT) - head; - } + return this.popBack(excess) + .then(() => { + const popHeight = this.hooks.getScrollHeight(); - return this.move([head + headDisplacement, tail + tailDisplacement]); + return this.pushFront(results).then(() => $q.resolve(popHeight)); + }); + }); + + return this.chain; }; this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => { + const previous = this.getPreviousRange(displacement); const [head, tail] = this.getRange(); - const headRoom = head - 1; - const headDisplacement = Math.min(headRoom, displacement); + this.chain = this.chain + .then(() => this.api.getRange(previous)) + .then(events => { + const results = getContinuous(events, true); + const max = Math.max(...results.map(({ counter }) => counter)); - const newHead = head - headDisplacement; + if (head > max + 1) { + return $q.resolve([]); + } - let tailDisplacement = 0; + return $q.resolve(results); + }) + .then(results => { + const count = (tail - head + results.length); + const excess = count - OUTPUT_EVENT_LIMIT; - if (tail - newHead > OUTPUT_EVENT_LIMIT) { - tailDisplacement = tail - (newHead + OUTPUT_EVENT_LIMIT); - } + return this.popFront(excess) + .then(() => { + const popHeight = this.hooks.getScrollHeight(); - return this.move([newHead, tail - tailDisplacement]); - }; - - this.moveHead = displacement => { - const [head, tail] = this.getRange(); - - const headRoom = head - 1; - const headDisplacement = Math.min(headRoom, displacement); - - return this.move([head + headDisplacement, tail]); - }; - - this.moveTail = displacement => { - const [head, tail] = this.getRange(); - - const tailRoom = this.getMaxCounter() - tail; - const tailDisplacement = Math.max(tailRoom, displacement); - - return this.move([head, tail + tailDisplacement]); - }; - - this.clear = () => { - const count = this.getRecordCount(); - - if (count > 0) { - this.chain = this.chain - .then(() => this.popBack(count)); - } + return this.pushBack(results).then(() => $q.resolve(popHeight)); + }); + }); return this.chain; }; - this.getFirst = () => this.clear() - .then(() => this.api.getFirst()) - .then(events => this.pushFront(events)) - .then(() => this.moveTail(OUTPUT_PAGE_SIZE)); + this.getFirst = () => { + this.chain = this.chain + .then(() => this.clear()) + .then(() => { + if (this.cache.first) { + return $q.resolve(this.cache.first); + } - this.getLast = () => this.clear() - .then(() => this.api.getLast()) - .then(events => this.pushBack(events)) - .then(() => this.moveHead(-OUTPUT_PAGE_SIZE)); + return this.api.getFirst(); + }) + .then(events => { + if (events.length === OUTPUT_PAGE_SIZE) { + this.cache.first = events; + } + + return this.pushFront(events); + }); + + return this.chain + .then(() => this.getNext()); + }; + + this.getLast = () => { + this.chain = this.chain + .then(() => this.getFrames()) + .then(frames => { + if (frames.length > 0) { + return $q.resolve(frames); + } + + return this.api.getLast(); + }) + .then(events => { + const min = Math.min(...events.map(({ counter }) => counter)); + + if (min <= this.getTailCounter() + 1) { + return this.pushFront(events); + } + + return this.clear() + .then(() => this.pushBack(events)); + }); + + return this.chain + .then(() => this.getPrevious()); + }; this.getTailCounter = () => { - const tail = Math.max(...Object.keys(this.records)); + if (this.state.tail === null) { + return 0; + } - return Number.isFinite(tail) ? tail : 0; + if (this.state.tail < 0) { + return 0; + } + + return this.state.tail; }; this.getHeadCounter = () => { - const head = Math.min(...Object.keys(this.records)); + if (this.state.head === null) { + return 0; + } - return Number.isFinite(head) ? head : 0; + if (this.state.head < 0) { + return 0; + } + + return this.state.head; + }; + + this.pushFrames = events => { + const frames = this.buffer.events.concat(events); + const [head, tail] = this.getRange(); + + let min; + let max; + let count = 0; + + for (let i = frames.length - 1; i >= 0; i--) { + count++; + + if (count > API_MAX_PAGE_SIZE) { + frames.splice(i, 1); + + count--; + continue; + } + + if (!min || frames[i].counter < min) { + min = frames[i].counter; + } + + if (!max || frames[i].counter > max) { + max = frames[i].counter; + } + } + + this.buffer.events = frames; + this.buffer.min = min; + this.buffer.max = max; + this.buffer.count = count; + + if (min >= head && min <= tail + 1) { + return frames.filter(({ counter }) => counter > tail); + } + + return []; + }; + + this.getFrames = () => $q.resolve(this.buffer.events); + + this.getMaxCounter = () => { + if (this.buffer.min) { + return this.buffer.min; + } + + return this.api.getMaxCounter(); }; this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE); - this.getMaxCounter = () => this.api.getMaxCounter(); this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; - this.getRecordCount = () => Object.keys(this.records).length; + this.getRecordCount = () => Object.keys(this.lines).length; this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount(); } 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;