diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index ecf247633c..0e9fc6b6a6 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -1,144 +1,110 @@ -const PAGE_LIMIT = 5; +const API_PAGE_SIZE = 200; const PAGE_SIZE = 50; +const ORDER_BY = 'counter'; const BASE_PARAMS = { - order_by: 'start_line', page_size: PAGE_SIZE, + order_by: ORDER_BY, }; const merge = (...objs) => _.merge({}, ...objs); -const getInitialState = params => ({ - results: [], - count: 0, - previous: 1, - page: 1, - next: 1, - last: 1, - params: merge(BASE_PARAMS, params), -}); - function JobEventsApiService ($http, $q) { this.init = (endpoint, params) => { - this.keys = []; - this.cache = {}; - this.pageSizes = {}; this.endpoint = endpoint; - this.state = getInitialState(params); + this.params = merge(BASE_PARAMS, params); + + this.state = { current: 0, count: 0 }; }; - this.getLastPage = count => Math.ceil(count / this.state.params.page_size); - - this.clearCache = () => { - delete this.cache; - delete this.keys; - delete this.pageSizes; - - this.cache = {}; - this.keys = []; - this.pageSizes = {}; - }; - - this.fetch = () => this.first().then(() => this); + this.fetch = () => this.getFirst().then(() => this); this.getPage = number => { - if (number < 1 || number > this.state.last) { - return $q.resolve(); - } + if (number === 1) return this.getFirst(); - if (this.cache[number]) { - if (this.pageSizes[number] === PAGE_SIZE) { - return this.cache[number]; - } + const [low, high] = [1 + PAGE_SIZE * (number - 1), PAGE_SIZE * number]; + const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); - delete this.pageSizes[number]; - delete this.cache[number]; - - this.keys.splice(this.keys.indexOf(number)); - } - - const { params } = this.state; - - delete params.page; - - params.page = number; - - const promise = $http.get(this.endpoint, { params }) + return $http.get(this.endpoint, { params }) .then(({ data }) => { - const { results, count } = data; + const { results } = data; - this.state.results = results; - this.state.count = count; - this.state.page = number; - this.state.last = this.getLastPage(count); - this.state.previous = Math.max(1, number - 1); - this.state.next = Math.min(this.state.last, number + 1); + this.state.current = number; - this.pageSizes[number] = results.length; - - return { results, page: number }; + return results; }); - - if (number === 1) { - this.clearCache(); - } - - this.cache[number] = promise; - this.keys.push(number); - - if (this.keys.length > PAGE_LIMIT) { - const remove = this.keys.shift(); - - delete this.cache[remove]; - delete this.pageSizes[remove]; - } - - return promise; }; - this.first = () => this.getPage(1); - this.next = () => this.getPage(this.state.next); - this.previous = () => this.getPage(this.state.previous); + this.getFirst = () => { + const page = 1; + const params = merge(this.params, { page }); - this.last = () => { - const params = merge({}, this.state.params); - - delete params.page; - delete params.order_by; - - params.page = 1; - params.order_by = '-start_line'; - - const promise = $http.get(this.endpoint, { params }) + return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results, count } = data; - const lastPage = this.getLastPage(count); + + this.state.count = count; + this.state.current = page; + + return results; + }); + }; + + this.getRange = range => { + if (!range) { + return $q.resolve([]); + } + + const [low, high] = range; + const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); + + params.page_size = API_PAGE_SIZE; + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results } = data; + const maxCounter = Math.max(results.map(({ counter }) => counter)); + + this.state.current = Math.ceil(maxCounter / PAGE_SIZE); + + return results; + }); + }; + + this.getLast = () => { + const params = merge(this.params, { page: 1, order_by: `-${ORDER_BY}` }); + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results } = data; + const count = Math.max(...results.map(({ counter }) => counter)); + + let rotated = results; if (count > PAGE_SIZE) { - results.splice(count % PAGE_SIZE); + rotated = results.splice(count % PAGE_SIZE); + + if (results.length > 0) { + rotated = results; + } } - - results.reverse(); - - this.state.results = results; this.state.count = count; - this.state.page = lastPage; - this.state.next = lastPage; - this.state.last = lastPage; - this.state.previous = Math.max(1, this.state.page - 1); + this.state.current = Math.ceil(count / PAGE_SIZE); - this.clearCache(); - - return { results, page: lastPage }; + return rotated; }); - - return promise; }; + + this.getCurrentPageNumber = () => this.state.current; + this.getLastPageNumber = () => Math.ceil(this.state.count / PAGE_SIZE); + this.getPreviousPageNumber = () => Math.max(1, this.state.current - 1); + this.getNextPageNumber = () => Math.min(this.state.current + 1, this.getLastPageNumber()); + this.getMaxCounter = () => this.state.count; + + this.getNext = () => this.getPage(this.getNextPageNumber()); + this.getPrevious = () => this.getPage(this.getPreviousPageNumber()); } -JobEventsApiService.$inject = [ - '$http', - '$q' -]; +JobEventsApiService.$inject = ['$http', '$q']; export default JobEventsApiService; diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 03fd2f9d02..33f143ecd5 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -729,7 +729,7 @@ JobDetailsController.$inject = [ 'OutputStrings', 'Wait', 'ParseVariableString', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 47f538a44c..c30cc68779 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -1,4 +1,5 @@ +
{{:: vm.strings.get('details.HEADER')}}
diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js deleted file mode 100644 index bb884e52af..0000000000 --- a/awx/ui/client/features/output/engine.service.js +++ /dev/null @@ -1,235 +0,0 @@ -const JOB_END = 'playbook_on_stats'; -const MAX_LAG = 120; - -function JobEventEngine ($q) { - this.init = ({ resource, scroll, page, onEventFrame, onStart, onStop }) => { - this.resource = resource; - this.scroll = scroll; - this.page = page; - - this.lag = 0; - this.count = 0; - this.pageCount = 0; - this.chain = $q.resolve(); - this.factors = this.getBatchFactors(this.resource.page.size); - - this.state = { - started: false, - paused: false, - pausing: false, - resuming: false, - ending: false, - ended: false, - counting: false, - }; - - this.hooks = { - onEventFrame, - onStart, - onStop, - }; - - this.lines = { - used: [], - missing: [], - ready: false, - min: 0, - max: 0 - }; - }; - - this.setMinLine = min => { - if (min > this.lines.min) { - this.lines.min = min; - } - }; - - this.getBatchFactors = size => { - const factors = [1]; - - for (let i = 2; i <= size / 2; i++) { - if (size % i === 0) { - factors.push(i); - } - } - - factors.push(size); - - return factors; - }; - - this.getBatchFactorIndex = () => { - const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); - - return index > this.factors.length - 1 ? this.factors.length - 1 : index; - }; - - this.setBatchFrameCount = () => { - const index = this.getBatchFactorIndex(); - - this.framesPerRender = this.factors[index]; - }; - - this.buffer = data => { - const pageAdded = this.page.addToBuffer(data); - - if (pageAdded) { - this.pageCount++; - this.setBatchFrameCount(); - - if (this.isPausing()) { - this.pause(true); - } else if (this.isResuming()) { - this.resume(true); - } - } - }; - - this.checkLines = data => { - for (let i = data.start_line; i < data.end_line; i++) { - if (i > this.lines.max) { - this.lines.max = i; - } - - this.lines.used.push(i); - } - - const missing = []; - for (let i = this.lines.min; i < this.lines.max; i++) { - if (this.lines.used.indexOf(i) === -1) { - missing.push(i); - } - } - - if (missing.length === 0) { - this.lines.ready = true; - this.lines.min = this.lines.max + 1; - this.lines.used = []; - } else { - this.lines.ready = false; - } - }; - - this.pushJobEvent = data => { - this.lag++; - - this.chain = this.chain - .then(() => { - if (data.end_line < this.lines.min) { - return $q.resolve(); - } - - if (!this.isActive()) { - this.start(); - } else if (data.event === JOB_END) { - if (this.isPaused()) { - this.end(true); - } else { - this.end(); - } - } - - this.checkLines(data); - this.buffer(data); - this.count++; - - if (!this.isReadyToRender()) { - return $q.resolve(); - } - - const events = this.page.emptyBuffer(); - this.count -= events.length; - - return this.renderFrame(events); - }) - .then(() => --this.lag); - - return this.chain; - }; - - this.renderFrame = events => this.hooks.onEventFrame(events) - .then(() => { - if (this.scroll.isLocked()) { - this.scroll.scrollToBottom(); - } - - if (this.isEnding()) { - const lastEvents = this.page.emptyBuffer(); - - if (lastEvents.length) { - return this.renderFrame(lastEvents); - } - - this.end(true); - } - - return $q.resolve(); - }); - - this.resume = done => { - if (done) { - this.state.resuming = false; - this.state.paused = false; - } else if (!this.isTransitioning()) { - this.scroll.pause(); - this.scroll.lock(); - this.scroll.scrollToBottom(); - this.state.resuming = true; - this.page.removeBookmark(); - } - }; - - this.pause = done => { - if (done) { - this.state.pausing = false; - this.state.paused = true; - this.scroll.resume(); - } else if (!this.isTransitioning()) { - this.scroll.pause(); - this.scroll.unlock(); - this.state.pausing = true; - this.page.setBookmark(); - } - }; - - this.start = () => { - if (!this.state.ending && !this.state.ended) { - this.state.started = true; - this.scroll.pause(); - this.scroll.lock(); - - this.hooks.onStart(); - } - }; - - this.end = done => { - if (done) { - this.state.ending = false; - this.state.ended = true; - this.scroll.unlock(); - this.scroll.resume(); - - this.hooks.onStop(); - - return; - } - - this.state.ending = true; - }; - - this.isReadyToRender = () => this.isDone() || - (!this.isPaused() && this.hasAllLines() && this.isBatchFull()); - this.hasAllLines = () => this.lines.ready; - this.isBatchFull = () => this.count % this.framesPerRender === 0; - this.isPaused = () => this.state.paused; - this.isPausing = () => this.state.pausing; - this.isResuming = () => this.state.resuming; - this.isTransitioning = () => this.isActive() && (this.state.pausing || this.state.resuming); - this.isActive = () => this.state.started && !this.state.ended; - this.isEnding = () => this.state.ending; - this.isDone = () => this.state.ended; -} - -JobEventEngine.$inject = ['$q']; - -export default JobEventEngine; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index d2daef915e..6a56b94bd8 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,129 +1,156 @@ +/* eslint camelcase: 0 */ let $compile; -let $filter; let $q; let $scope; let $state; -let page; -let render; let resource; +let render; let scroll; -let engine; let status; +let slide; +let stream; let vm; -let streaming; -let listeners = []; -function JobsIndexController ( - _$compile_, - _$filter_, - _$q_, - _$scope_, - _$state_, - _resource_, - _page_, - _scroll_, - _render_, - _engine_, - _status_, - _strings_, -) { - vm = this || {}; +const bufferState = [0, 0]; // [length, count] +const listeners = []; +const rx = []; - $compile = _$compile_; - $filter = _$filter_; - $q = _$q_; - $scope = _$scope_; - $state = _$state_; +let following = false; - resource = _resource_; - page = _page_; - scroll = _scroll_; - render = _render_; - engine = _engine_; - status = _status_; +function bufferInit () { + rx.length = 0; - vm.strings = _strings_; - - // Development helper(s) - vm.clear = devClear; - - // Expand/collapse - vm.expanded = false; - vm.toggleExpanded = toggleExpanded; - - // Panel - vm.resource = resource; - vm.title = $filter('sanitize')(resource.model.get('name')); - - // Stdout Navigation - vm.scroll = { - showBackToTop: false, - home: scrollFirst, - end: scrollLast, - down: scrollPageDown, - up: scrollPageUp - }; - - render.requestAnimationFrame(() => init()); + bufferState[0] = 0; + bufferState[1] = 0; } -function init () { - status.init({ - resource, - }); +function bufferAdd (event) { + rx.push(event); - page.init({ - resource, - }); + bufferState[0] += 1; + bufferState[1] += 1; - render.init({ - compile: html => $compile(html)($scope), - isStreamActive: engine.isActive, - }); + return bufferState; +} - scroll.init({ - isAtRest: scrollIsAtRest, - previous, - next, - }); +function bufferEmpty () { + bufferState[0] = 0; - engine.init({ - page, - scroll, - resource, - onEventFrame (events) { - return shift().then(() => append(events, true)); - }, - onStart () { - status.setJobStatus('running'); - }, - onStop () { - stopListening(); - status.updateStats(); - status.dispatch(); + return rx.splice(0, rx.length); +} + +function onFrames (events) { + if (!following) { + const minCounter = Math.min(...events.map(({ counter }) => counter)); + // attachment range + const max = slide.getTailCounter() + 1; + const min = Math.max(1, slide.getHeadCounter(), max - 50); + + if (minCounter > max || minCounter < min) { + return $q.resolve(); } - }); - streaming = false; - - if (status.state.running) { - return scrollLast().then(() => startListening()); - } else if (!status.state.finished) { - return scrollFirst().then(() => startListening()); + follow(); } - return scrollLast(); + const capacity = slide.getCapacity(); + + if (capacity >= events.length) { + return slide.pushFront(events); + } + + delete render.record; + + render.record = {}; + + return slide.popBack(events.length - capacity) + .then(() => slide.pushFront(events)) + .then(() => { + scroll.setScrollPosition(scroll.getScrollHeight()); + + return $q.resolve(); + }); +} + +function first () { + unfollow(); + scroll.pause(); + + return slide.getFirst() + .then(() => { + scroll.resetScrollPosition(); + scroll.resume(); + + return $q.resolve(); + }); +} + +function next () { + return slide.slideDown(); +} + +function previous () { + unfollow(); + + const initialPosition = scroll.getScrollPosition(); + + return slide.slideUp() + .then(changed => { + if (changed[0] !== 0 || changed[1] !== 0) { + const currentHeight = scroll.getScrollHeight(); + scroll.setScrollPosition((currentHeight / 4) - initialPosition); + } + + return $q.resolve(); + }); +} + +function last () { + scroll.pause(); + + return slide.getLast() + .then(() => { + stream.setMissingCounterThreshold(slide.getTailCounter() + 1); + scroll.setScrollPosition(scroll.getScrollHeight()); + + scroll.resume(); + + return $q.resolve(); + }); +} + +function compile (html) { + return $compile(html)($scope); +} + +function follow () { + scroll.pause(); + scroll.hide(); + + following = true; +} + +function unfollow () { + following = false; + + scroll.unhide(); + scroll.resume(); +} + +function showHostDetails (id, uuid) { + $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); } function stopListening () { listeners.forEach(deregister => deregister()); - listeners = []; + listeners.length = 0; } function startListening () { stopListening(); + listeners.push($scope.$on(resource.ws.events, (scope, data) => handleJobEvent(data))); listeners.push($scope.$on(resource.ws.status, (scope, data) => handleStatusEvent(data))); } @@ -133,271 +160,95 @@ function handleStatusEvent (data) { } function handleJobEvent (data) { - streaming = streaming || attachToRunningJob(); + stream.pushJobEvent(data); + status.pushJobEvent(data); +} - streaming.then(() => { - engine.pushJobEvent(data); - status.pushJobEvent(data); +function OutputIndexController ( + _$compile_, + _$q_, + _$scope_, + _$state_, + _resource_, + _scroll_, + _render_, + _status_, + _slide_, + _stream_, + $filter, + strings, +) { + $compile = _$compile_; + $q = _$q_; + $scope = _$scope_; + $state = _$state_; + + resource = _resource_; + scroll = _scroll_; + render = _render_; + slide = _slide_; + status = _status_; + stream = _stream_; + + vm = this || {}; + + // Panel + vm.strings = strings; + vm.resource = resource; + vm.title = $filter('sanitize')(resource.model.get('name')); + + vm.expanded = false; + vm.showHostDetails = showHostDetails; + vm.toggleExpanded = () => { vm.expanded = !vm.expanded; }; + + // Stdout Navigation + vm.menu = { + end: last, + home: first, + up: previous, + down: next, + }; + + render.requestAnimationFrame(() => { + bufferInit(); + + status.init(resource); + slide.init(render, resource.events); + + render.init({ compile }); + scroll.init({ previous, next }); + + stream.init({ + bufferAdd, + bufferEmpty, + onFrames, + onStop () { + stopListening(); + status.updateStats(); + status.dispatch(); + unfollow(); + } + }); + + startListening(); + + return last(); }); } -function next () { - return page.next() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return shift() - .then(() => append(events)) - .then(() => { - if (scroll.isMissing()) { - return next(); - } - - return $q.resolve(); - }); - }); -} - -function previous () { - const initialPosition = scroll.getScrollPosition(); - let postPopHeight; - - return page.previous() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return pop() - .then(() => { - postPopHeight = scroll.getScrollHeight(); - - return prepend(events); - }) - .then(() => { - const currentHeight = scroll.getScrollHeight(); - scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition); - }); - }); -} - -function append (events, eng) { - return render.append(events) - .then(count => { - page.updateLineCount(count, eng); - }); -} - -function prepend (events) { - return render.prepend(events) - .then(count => { - page.updateLineCount(count); - }); -} - -function pop () { - if (!page.isOverCapacity()) { - return $q.resolve(); - } - - const lines = page.trim(); - - return render.pop(lines); -} - -function shift () { - if (!page.isOverCapacity()) { - return $q.resolve(); - } - - const lines = page.trim(true); - - return render.shift(lines); -} - -function scrollFirst () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (!engine.isPaused()) { - engine.pause(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return page.first() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return render.clear() - .then(() => prepend(events)) - .then(() => { - scroll.resetScrollPosition(); - scroll.resume(); - }) - .then(() => { - if (scroll.isMissing()) { - return next(); - } - - return $q.resolve(); - }); - }); -} - -function scrollLast () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (engine.isPaused()) { - engine.resume(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return render.clear() - .then(() => page.last()) - .then(events => { - if (!events) { - return $q.resolve(); - } - - const minLine = 1 + Math.max(...events.map(event => event.end_line)); - engine.setMinLine(minLine); - - return append(events); - }) - .then(() => { - if (!engine.isActive()) { - scroll.resume(); - } - scroll.setScrollPosition(scroll.getScrollHeight()); - }) - .then(() => { - if (!engine.isActive() && scroll.isMissing()) { - return previous(); - } - - return $q.resolve(); - }); -} - -function attachToRunningJob () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (engine.isPaused()) { - engine.resume(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return page.last() - .then(events => { - if (!events) { - return $q.resolve(); - } - - const minLine = 1 + Math.max(...events.map(event => event.end_line)); - engine.setMinLine(minLine); - - return append(events); - }) - .then(() => { - scroll.setScrollPosition(scroll.getScrollHeight()); - }); -} - -function scrollPageUp () { - if (scroll.isPaused()) { - return; - } - - scroll.pageUp(); -} - -function scrollPageDown () { - if (scroll.isPaused()) { - return; - } - - scroll.pageDown(); -} - -function scrollIsAtRest (isAtRest) { - vm.scroll.showBackToTop = !isAtRest; -} - -function toggleExpanded () { - vm.expanded = !vm.expanded; -} - -function devClear () { - render.clear().then(() => init()); -} - -function showHostDetails (id, uuid) { - $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); -} - -// function toggle (uuid, menu) { -// const lines = $(`.child-of-${uuid}`); -// let icon = $(`#${uuid} .at-Stdout-toggle > i`); - -// if (menu || record[uuid].level === 1) { -// vm.isExpanded = !vm.isExpanded; -// } - -// if (record[uuid].children) { -// icon = icon.add($(`#${record[uuid].children.join(', #')}`) -// .find('.at-Stdout-toggle > i')); -// } - -// if (icon.hasClass('fa-angle-down')) { -// icon.addClass('fa-angle-right'); -// icon.removeClass('fa-angle-down'); - -// lines.addClass('hidden'); -// } else { -// icon.addClass('fa-angle-down'); -// icon.removeClass('fa-angle-right'); - -// lines.removeClass('hidden'); -// } -// } - -JobsIndexController.$inject = [ +OutputIndexController.$inject = [ '$compile', - '$filter', '$q', '$scope', '$state', 'resource', - 'JobPageService', - 'JobScrollService', - 'JobRenderService', - 'JobEventEngine', - 'JobStatusService', + 'OutputScrollService', + 'OutputRenderService', + 'OutputStatusService', + 'OutputSlideService', + 'OutputStreamService', + '$filter', 'OutputStrings', ]; -module.exports = JobsIndexController; +module.exports = OutputIndexController; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index de29d1b4c6..f124edb342 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -3,13 +3,13 @@ import atLibComponents from '~components'; import Strings from '~features/output/output.strings'; import Controller from '~features/output/index.controller'; -import PageService from '~features/output/page.service'; import RenderService from '~features/output/render.service'; import ScrollService from '~features/output/scroll.service'; -import EngineService from '~features/output/engine.service'; +import StreamService from '~features/output/stream.service'; import StatusService from '~features/output/status.service'; import MessageService from '~features/output/message.service'; import EventsApiService from '~features/output/api.events.service'; +import SlideService from '~features/output/slide.service'; import LegacyRedirect from '~features/output/legacy.route'; import DetailsComponent from '~features/output/details.component'; @@ -24,6 +24,8 @@ const MODULE_NAME = 'at.features.output'; const PAGE_CACHE = true; const PAGE_LIMIT = 5; const PAGE_SIZE = 50; +const ORDER_BY = 'counter'; +// const ORDER_BY = 'start_line'; const WS_PREFIX = 'ws'; function resolveResource ( @@ -74,7 +76,7 @@ function resolveResource ( const params = { page_size: PAGE_SIZE, - order_by: 'start_line', + order_by: ORDER_BY, }; const config = { @@ -250,13 +252,13 @@ angular HostEvent ]) .service('OutputStrings', Strings) - .service('JobPageService', PageService) - .service('JobScrollService', ScrollService) - .service('JobRenderService', RenderService) - .service('JobEventEngine', EngineService) - .service('JobStatusService', StatusService) - .service('JobMessageService', MessageService) + .service('OutputScrollService', ScrollService) + .service('OutputRenderService', RenderService) + .service('OutputStreamService', StreamService) + .service('OutputStatusService', StatusService) + .service('OutputMessageService', MessageService) .service('JobEventsApiService', EventsApiService) + .service('OutputSlideService', SlideService) .component('atJobSearch', SearchComponent) .component('atJobStats', StatsComponent) .component('atJobDetails', DetailsComponent) diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index bfe204958e..441879b21f 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -22,17 +22,17 @@ ng-class="{ 'fa-minus': vm.expanded, 'fa-plus': !vm.expanded }">
-
+
+ ng-class=" { 'at-Stdout-menuIcon--active': vm.menu.isLocked }">
-
+
-
+
-
+
@@ -52,8 +52,8 @@ -
-
+
+

{{:: vm.strings.get('stdout.BACK_TO_TOP') }}

diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js deleted file mode 100644 index b8d9f96fb2..0000000000 --- a/awx/ui/client/features/output/page.service.js +++ /dev/null @@ -1,283 +0,0 @@ -function JobPageService ($q) { - this.init = ({ resource }) => { - this.resource = resource; - this.api = this.resource.events; - - this.page = { - limit: this.resource.page.pageLimit, - size: this.resource.page.size, - cache: [], - state: { - count: 0, - current: 0, - first: 0, - last: 0 - } - }; - - this.bookmark = { - pending: false, - set: true, - cache: [], - state: { - count: 0, - first: 0, - last: 0, - current: 0 - } - }; - - this.result = { - limit: this.page.limit * this.page.size, - count: 0 - }; - - this.buffer = { - count: 0 - }; - }; - - this.addPage = (number, events, push, reference) => { - const page = { number, events, lines: 0 }; - reference = reference || this.getActiveReference(); - - if (push) { - reference.cache.push(page); - reference.state.last = page.number; - reference.state.first = reference.cache[0].number; - } else { - reference.cache.unshift(page); - reference.state.first = page.number; - reference.state.last = reference.cache[reference.cache.length - 1].number; - } - - reference.state.current = page.number; - reference.state.count++; - }; - - this.addToBuffer = event => { - const reference = this.getReference(); - const index = reference.cache.length - 1; - let pageAdded = false; - - if (this.result.count % this.page.size === 0) { - this.addPage(reference.state.current + 1, [event], true, reference); - - if (this.isBookmarkPending()) { - this.setBookmark(); - } - - this.trimBuffer(); - - pageAdded = true; - } else { - reference.cache[index].events.push(event); - } - - this.buffer.count++; - this.result.count++; - - return pageAdded; - }; - - this.trimBuffer = () => { - const reference = this.getReference(); - const diff = reference.cache.length - this.page.limit; - - if (diff <= 0) { - return; - } - - for (let i = 0; i < diff; i++) { - if (reference.cache[i].events) { - this.buffer.count -= reference.cache[i].events.length; - reference.cache[i].events.splice(0, reference.cache[i].events.length); - } - } - }; - - this.isBufferFull = () => { - if (this.buffer.count === 2) { - return true; - } - - return false; - }; - - this.emptyBuffer = () => { - const reference = this.getReference(); - let data = []; - - for (let i = 0; i < reference.cache.length; i++) { - const count = reference.cache[i].events.length; - - if (count > 0) { - this.buffer.count -= count; - data = data.concat(reference.cache[i].events.splice(0, count)); - } - } - - return data; - }; - - this.emptyCache = number => { - const reference = this.getActiveReference(); - - number = number || reference.state.current; - - reference.state.first = number; - reference.state.current = number; - reference.state.last = number; - - reference.cache.splice(0, reference.cache.length); - }; - - this.isOverCapacity = () => { - const reference = this.getActiveReference(); - - return (reference.cache.length - this.page.limit) > 0; - }; - - this.trim = left => { - const reference = this.getActiveReference(); - const excess = reference.cache.length - this.page.limit; - - let ejected; - - if (left) { - ejected = reference.cache.splice(0, excess); - reference.state.first = reference.cache[0].number; - } else { - ejected = reference.cache.splice(-excess); - reference.state.last = reference.cache[reference.cache.length - 1].number; - } - - return ejected.reduce((total, page) => total + page.lines, 0); - }; - - this.isPageBookmarked = number => number >= this.page.bookmark.first && - number <= this.page.bookmark.last; - - this.updateLineCount = (lines, engine) => { - let reference; - - if (engine) { - reference = this.getReference(); - } else { - reference = this.getActiveReference(); - } - - const index = reference.cache.findIndex(item => item.number === reference.state.current); - - reference.cache[index].lines += lines; - }; - - this.isBookmarkPending = () => this.bookmark.pending; - this.isBookmarkSet = () => this.bookmark.set; - - this.setBookmark = () => { - if (this.isBookmarkSet()) { - return; - } - - if (!this.isBookmarkPending()) { - this.bookmark.pending = true; - - return; - } - - this.bookmark.state.first = this.page.state.first; - this.bookmark.state.last = this.page.state.last - 1; - this.bookmark.state.current = this.page.state.current - 1; - this.bookmark.cache = JSON.parse(JSON.stringify(this.page.cache)); - this.bookmark.set = true; - this.bookmark.pending = false; - }; - - this.removeBookmark = () => { - this.bookmark.set = false; - this.bookmark.pending = false; - this.bookmark.cache.splice(0, this.bookmark.cache.length); - this.bookmark.state.first = 0; - this.bookmark.state.last = 0; - this.bookmark.state.current = 0; - }; - - this.next = () => { - const reference = this.getActiveReference(); - const number = reference.state.last + 1; - - return this.api.getPage(number) - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.addPage(data.page, [], true); - - return data.results; - }); - }; - - this.previous = () => { - const reference = this.getActiveReference(); - - return this.api.getPage(reference.state.first - 1) - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.addPage(data.page, [], false); - - return data.results; - }); - }; - - this.last = () => this.api.last() - .then(data => { - if (!data || !data.results || !data.results.length > 0) { - return $q.resolve(); - } - - this.emptyCache(data.page); - this.addPage(data.page, [], true); - - return data.results; - }); - - this.first = () => this.api.first() - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.emptyCache(data.page); - this.addPage(data.page, [], false); - - return data.results; - }); - - this.getActiveReference = () => (this.isBookmarkSet() ? - this.getReference(true) : this.getReference()); - - this.getReference = (bookmark) => { - if (bookmark) { - return { - bookmark: true, - cache: this.bookmark.cache, - state: this.bookmark.state - }; - } - - return { - bookmark: false, - cache: this.page.cache, - state: this.page.state - }; - }; -} - -JobPageService.$inject = ['$q']; - -export default JobPageService; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js index a568813ddc..6ebafe09e4 100644 --- a/awx/ui/client/features/output/scroll.service.js +++ b/awx/ui/client/features/output/scroll.service.js @@ -4,7 +4,7 @@ const DELAY = 100; const THRESHOLD = 0.1; function JobScrollService ($q, $timeout) { - this.init = (hooks) => { + this.init = ({ next, previous }) => { this.el = $(ELEMENT_CONTAINER); this.timer = null; @@ -14,15 +14,15 @@ function JobScrollService ($q, $timeout) { }; this.hooks = { - isAtRest: hooks.isAtRest, - next: hooks.next, - previous: hooks.previous + next, + previous, + isAtRest: () => $q.resolve() }; this.state = { - locked: false, + hidden: false, paused: false, - top: true + top: true, }; this.el.scroll(this.listen); @@ -158,6 +158,20 @@ function JobScrollService ($q, $timeout) { this.state.locked = false; }; + this.hide = () => { + if (!this.state.hidden) { + this.el.css('overflow', 'hidden'); + this.state.hidden = true; + } + }; + + this.unhide = () => { + if (this.state.hidden) { + this.el.css('overflow', 'auto'); + this.state.hidden = false; + } + }; + this.isLocked = () => this.state.locked; this.isMissing = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); } diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index 490b7c283e..a6dcd3aa4a 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -132,7 +132,7 @@ JobSearchController.$inject = [ '$state', 'QuerySet', 'OutputStrings', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index 702b4749b3..ea91fc3de6 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -1,39 +1,37 @@ - + +
-
- - - - + - + - -
-

- {{ vm.message }} -

+ type="button"> {{:: vm.strings.get('search.KEY') }} + + +
+

{{ vm.message }}

@@ -41,19 +39,25 @@
-
-
- {{:: vm.strings.get('search.EXAMPLES') }}: +
+
+ {{:: vm.strings.get('search.EXAMPLES') }}: +
+
- -
{{:: vm.strings.get('search.FIELDS') }}: diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js new file mode 100644 index 0000000000..afb8044280 --- /dev/null +++ b/awx/ui/client/features/output/slide.service.js @@ -0,0 +1,298 @@ +/* eslint camelcase: 0 */ +const PAGE_SIZE = 50; +const PAGE_LIMIT = 5; +const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE; + +const TAIL_ADDITION = 'TAIL_ADDITION'; +const TAIL_DELETION = 'TAIL_DELETION'; +const HEAD_ADDITION = 'HEAD_ADDITION'; +const HEAD_DELETION = 'HEAD_DELETION'; + +function SlidingWindowService ($q) { + this.init = (storage, api) => { + const { prepend, append, shift, pop } = storage; + const { getMaxCounter, getRange, getFirst, getLast } = api; + + this.api = { + getMaxCounter, + getRange, + getFirst, + getLast + }; + + this.storage = { + prepend, + append, + shift, + pop + }; + + this.commands = { + [TAIL_ADDITION]: this.pushFront, + [HEAD_ADDITION]: this.pushBack, + [TAIL_DELETION]: this.popFront, + [HEAD_DELETION]: this.popBack + }; + + this.vectors = { + [TAIL_ADDITION]: [0, 1], + [HEAD_ADDITION]: [-1, 0], + [TAIL_DELETION]: [0, -1], + [HEAD_DELETION]: [1, 0], + }; + + this.records = {}; + this.chain = $q.resolve(); + }; + + this.pushFront = events => { + const tail = this.getTailCounter(); + const newEvents = events.filter(({ counter }) => counter > tail); + + return this.storage.append(newEvents) + .then(() => { + newEvents.forEach(({ counter, start_line, end_line }) => { + this.records[counter] = { start_line, end_line }; + }); + + return $q.resolve(); + }); + }; + + this.pushBack = events => { + const [head, tail] = this.getRange(); + const newEvents = events + .filter(({ counter }) => counter < head || counter > tail); + + return this.storage.prepend(newEvents) + .then(() => { + newEvents.forEach(({ counter, start_line, end_line }) => { + this.records[counter] = { start_line, end_line }; + }); + + return $q.resolve(); + }); + }; + + this.popFront = count => { + if (!count || count <= 0) { + return $q.resolve(); + } + + const max = this.getTailCounter(); + const min = Math.max(this.getHeadCounter(), max - count); + + 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); + } + } + + return this.storage.pop(lines) + .then(() => { + for (let i = min; i <= max; ++i) { + delete this.records[i]; + } + + return $q.resolve(); + }); + }; + + this.popBack = count => { + if (!count || count <= 0) { + return $q.resolve(); + } + + const min = this.getHeadCounter(); + const max = Math.min(this.getTailCounter(), min + count); + + 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); + } + } + + return this.storage.shift(lines) + .then(() => { + for (let i = min; i <= max; ++i) { + delete this.records[i]; + } + + return $q.resolve(); + }); + }; + + this.getBoundedRange = ([low, high]) => { + const bounds = [1, this.getMaxCounter()]; + + return [Math.max(low, bounds[0]), Math.min(high, bounds[1])]; + }; + + this.move = ([low, high]) => { + const [head, tail] = this.getRange(); + const [newHead, newTail] = this.getBoundedRange([low, high]); + + if (newHead > newTail) { + return $q.resolve([0, 0]); + } + + if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) { + return $q.resolve([0, 0]); + } + + const additions = []; + const deletions = []; + + for (let counter = tail + 1; counter <= newTail; counter++) { + additions.push([counter, TAIL_ADDITION]); + } + + for (let counter = head - 1; counter >= newHead; counter--) { + additions.push([counter, HEAD_ADDITION]); + } + + for (let counter = head; counter < newHead; counter++) { + deletions.push([counter, HEAD_DELETION]); + } + + for (let counter = tail; counter > newTail; counter--) { + deletions.push([counter, TAIL_DELETION]); + } + + const hasCounter = (items, n) => items + .filter(([counter]) => counter === n).length !== 0; + + const commandRange = { + [TAIL_DELETION]: 0, + [HEAD_DELETION]: 0, + [TAIL_ADDITION]: [tail, tail], + [HEAD_ADDITION]: [head, head], + }; + + deletions.forEach(([counter, key]) => { + if (!hasCounter(additions, counter)) { + commandRange[key] += 1; + } + + commandRange[TAIL_ADDITION][0] += this.vectors[key][0]; + commandRange[TAIL_ADDITION][1] += this.vectors[key][1]; + + commandRange[HEAD_ADDITION][0] += this.vectors[key][0]; + commandRange[HEAD_ADDITION][1] += this.vectors[key][1]; + }); + + additions.forEach(([counter, key]) => { + if (!hasCounter(deletions, counter)) { + if (counter < commandRange[key][0]) { + commandRange[key][0] = counter; + } + + if (counter > commandRange[key][1]) { + commandRange[key][1] = counter; + } + } + }); + + this.chain = this.chain + .then(() => this.commands[TAIL_DELETION](commandRange[TAIL_DELETION])) + .then(() => this.commands[HEAD_DELETION](commandRange[HEAD_DELETION])) + .then(() => this.api.getRange(commandRange[TAIL_ADDITION])) + .then(events => this.commands[TAIL_ADDITION](events)) + .then(() => this.api.getRange(commandRange[HEAD_ADDITION])) + .then(events => this.commands[HEAD_ADDITION](events)) + .then(() => { + const range = this.getRange(); + const displacement = [range[0] - head, range[1] - tail]; + + return $q.resolve(displacement); + }); + + return this.chain; + }; + + this.slideDown = (displacement = PAGE_SIZE) => { + const [head, tail] = this.getRange(); + + const tailRoom = this.getMaxCounter() - tail; + const tailDisplacement = Math.min(tailRoom, displacement); + const headDisplacement = Math.min(tailRoom, displacement); + + return this.move([head + headDisplacement, tail + tailDisplacement]); + }; + + this.slideUp = (displacement = PAGE_SIZE) => { + const [head, tail] = this.getRange(); + + const headRoom = head - 1; + const headDisplacement = Math.min(headRoom, displacement); + const tailDisplacement = Math.min(headRoom, displacement); + + return this.move([head - headDisplacement, 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.commands[HEAD_DELETION](count)); + } + + return this.chain; + }; + + this.getFirst = () => this.clear() + .then(() => this.api.getFirst()) + .then(events => this.commands[TAIL_ADDITION](events)) + .then(() => this.moveTail(PAGE_SIZE)); + + this.getLast = () => this.clear() + .then(() => this.api.getLast()) + .then(events => this.commands[HEAD_ADDITION](events)) + .then(() => this.moveHead(-PAGE_SIZE)); + + this.getTailCounter = () => { + const tail = Math.max(...Object.keys(this.records)); + + return Number.isFinite(tail) ? tail : 0; + }; + + this.getHeadCounter = () => { + const head = Math.min(...Object.keys(this.records)); + + return Number.isFinite(head) ? head : 0; + }; + + this.compareRange = (a, b) => a[0] === b[0] && a[1] === b[1]; + this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; + + this.getMaxCounter = () => this.api.getMaxCounter(); + this.getRecordCount = () => Object.keys(this.records).length; + this.getCapacity = () => EVENT_LIMIT - this.getRecordCount(); +} + +SlidingWindowService.$inject = ['$q']; + +export default SlidingWindowService; diff --git a/awx/ui/client/features/output/stats.component.js b/awx/ui/client/features/output/stats.component.js index d9051727de..7ce5e31af1 100644 --- a/awx/ui/client/features/output/stats.component.js +++ b/awx/ui/client/features/output/stats.component.js @@ -63,7 +63,7 @@ function JobStatsController (strings, { subscribe }) { JobStatsController.$inject = [ 'OutputStrings', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html index c1c7fdb4a7..151ae1d23b 100644 --- a/awx/ui/client/features/output/stats.partial.html +++ b/awx/ui/client/features/output/stats.partial.html @@ -1,4 +1,4 @@ - +
plays ... diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index 717744fea7..dfb7d40e04 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -12,9 +12,7 @@ function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); this.subscribe = listener => message.subscribe('status', listener); - this.init = ({ resource }) => { - const { model } = resource; - + this.init = ({ model, stats }) => { this.created = model.get('created'); this.job = model.get('id'); this.jobType = model.get('type'); @@ -43,7 +41,7 @@ function JobStatusService (moment, message) { }, }; - this.setStatsEvent(resource.stats); + this.setStatsEvent(stats); this.updateStats(); this.updateRunningState(); @@ -213,7 +211,7 @@ function JobStatusService (moment, message) { JobStatusService.$inject = [ 'moment', - 'JobMessageService', + 'OutputMessageService', ]; export default JobStatusService; diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js new file mode 100644 index 0000000000..da73f70c23 --- /dev/null +++ b/awx/ui/client/features/output/stream.service.js @@ -0,0 +1,146 @@ +/* eslint camelcase: 0 */ +const PAGE_SIZE = 50; +const MAX_LAG = 120; +const JOB_END = 'playbook_on_stats'; + +function OutputStream ($q) { + this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => { + this.hooks = { + bufferAdd, + bufferEmpty, + onFrames, + onStop, + }; + + this.counters = { + used: [], + min: 1, + max: 0, + ready: false, + }; + + this.state = { + ending: false, + ended: false + }; + + this.lag = 0; + this.chain = $q.resolve(); + + this.factors = this.calcFactors(PAGE_SIZE); + this.setFramesPerRender(); + }; + + this.calcFactors = size => { + const factors = [1]; + + for (let i = 2; i <= size / 2; i++) { + if (size % i === 0) { + factors.push(i); + } + } + + factors.push(size); + + return factors; + }; + + this.setFramesPerRender = () => { + const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); + const boundedIndex = Math.min(this.factors.length - 1, index); + + this.framesPerRender = this.factors[boundedIndex]; + }; + + this.setMissingCounterThreshold = counter => { + if (counter > this.counters.min) { + this.counters.min = counter; + } + }; + + this.updateCounterState = ({ counter }) => { + this.counters.used.push(counter); + + if (counter > this.counters.max) { + this.counters.max = counter; + } + + const missing = []; + const ready = []; + + for (let i = this.counters.min; i < this.counters.max; i++) { + if (this.counters.used.indexOf(i) === -1) { + missing.push(i); + } else if (missing.length === 0) { + ready.push(i); + } + } + + if (missing.length === 0) { + this.counters.ready = true; + this.counters.min = this.counters.max + 1; + this.counters.used = []; + } else { + this.counters.ready = false; + } + + this.counters.missing = missing; + this.counters.readyLines = ready; + + return this.counters.ready; + }; + + this.pushJobEvent = data => { + this.lag++; + + this.chain = this.chain + .then(() => { + if (data.event === JOB_END) { + this.state.ending = true; + } + + const isMissingCounters = !this.updateCounterState(data); + const [length, count] = this.hooks.bufferAdd(data); + + if (count % PAGE_SIZE === 0) { + this.setFramesPerRender(); + } + + const isBatchReady = length % this.framesPerRender === 0; + const isReady = this.state.ended || (!isMissingCounters && isBatchReady); + + if (!isReady) { + return $q.resolve(); + } + + const events = this.hooks.bufferEmpty(); + + return this.emitFrames(events); + }) + .then(() => --this.lag); + + return this.chain; + }; + + this.emitFrames = events => this.hooks.onFrames(events) + .then(() => { + if (this.state.ending) { + const lastEvents = this.hooks.bufferEmpty(); + + if (lastEvents.length) { + return this.emitFrames(lastEvents); + } + + this.state.ending = false; + this.state.ended = true; + + this.hooks.onStop(); + } + + return $q.resolve(); + }); +} + +OutputStream.$inject = ['$q']; + +export default OutputStream;