diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index a7a16c75c8..3a4e033cfa 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -14,10 +14,22 @@ function JobEventsApiService ($http, $q) { this.endpoint = endpoint; this.params = merge(BASE_PARAMS, params); - this.state = { current: 0, count: 0 }; + this.state = { count: 0, maxCounter: 0 }; + this.cache = {}; }; - this.fetch = () => this.getLast().then(() => this); + this.clearCache = () => { + Object.keys(this.cache).forEach(key => { + delete this.cache[key]; + }); + }; + + this.fetch = () => this.getLast() + .then(results => { + this.cache.last = results; + + return this; + }); this.getFirst = () => { const page = 1; @@ -26,14 +38,72 @@ function JobEventsApiService ($http, $q) { return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results, count } = data; + const maxCounter = Math.max(...results.map(({ counter }) => counter)); this.state.count = count; - this.state.current = page; + + if (maxCounter > this.state.maxCounter) { + this.state.maxCounter = maxCounter; + } return results; }); }; + this.getPage = number => { + if (number < 1 || number > this.getLastPageNumber()) { + return $q.resolve([]); + } + + const params = merge(this.params, { page: number }); + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results, count } = data; + const maxCounter = Math.max(...results.map(({ counter }) => counter)); + + this.state.count = count; + + if (maxCounter > this.state.maxCounter) { + this.state.maxCounter = maxCounter; + } + + return results; + }); + }; + + this.getLast = () => { + if (this.cache.last) { + return $q.resolve(this.cache.last); + } + + const params = merge(this.params, { page: 1, order_by: `-${ORDER_BY}` }); + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results, count } = data; + const maxCounter = Math.max(...results.map(({ counter }) => counter)); + + let rotated = results; + + if (count > PAGE_SIZE) { + rotated = results.splice(count % PAGE_SIZE); + + if (results.length > 0) { + rotated = results; + } + } + + this.state.count = count; + + if (maxCounter > this.state.maxCounter) { + this.state.maxCounter = maxCounter; + } + + return rotated; + }); + }; + this.getRange = range => { if (!range) { return $q.resolve([]); @@ -47,46 +117,18 @@ function JobEventsApiService ($http, $q) { return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results } = data; - const maxCounter = Math.max(results.map(({ counter }) => counter)); + const maxCounter = Math.max(...results.map(({ counter }) => counter)); - this.state.current = Math.ceil(maxCounter / PAGE_SIZE); + if (maxCounter > this.state.maxCounter) { + this.state.maxCounter = maxCounter; + } 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) { - rotated = results.splice(count % PAGE_SIZE); - - if (results.length > 0) { - rotated = results; - } - } - this.state.count = count; - this.state.current = Math.ceil(count / PAGE_SIZE); - - return rotated; - }); - }; - - 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()); + this.getMaxCounter = () => this.state.maxCounter; } JobEventsApiService.$inject = ['$http', '$q']; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index ae249b1210..529d319aab 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -92,7 +92,7 @@ function first () { } function next () { - return slide.slideDown(); + return slide.getNext(); } function previous () { @@ -100,7 +100,7 @@ function previous () { const initialPosition = scroll.getScrollPosition(); - return slide.slideUp() + return slide.getPrevious() .then(popHeight => { const currentHeight = scroll.getScrollHeight(); scroll.setScrollPosition(currentHeight - popHeight + initialPosition); @@ -255,6 +255,7 @@ function OutputIndexController ( _$state_, _resource_, _scroll_, + _page_, _render_, _status_, _slide_, @@ -263,6 +264,8 @@ function OutputIndexController ( strings, $stateParams, ) { + const { isPanelExpanded } = $stateParams; + $compile = _$compile_; $q = _$q_; $scope = _$scope_; @@ -271,9 +274,9 @@ function OutputIndexController ( resource = _resource_; scroll = _scroll_; render = _render_; - slide = _slide_; status = _status_; stream = _stream_; + slide = resource.model.get('event_processing_finished') ? _page_ : _slide_; vm = this || {}; @@ -281,8 +284,6 @@ function OutputIndexController ( vm.title = $filter('sanitize')(resource.model.get('name')); vm.strings = strings; vm.resource = resource; - - const { isPanelExpanded } = $stateParams; vm.reloadState = reloadState; vm.isPanelExpanded = isPanelExpanded; vm.togglePanelExpand = togglePanelExpand; @@ -334,6 +335,7 @@ OutputIndexController.$inject = [ '$state', 'resource', 'OutputScrollService', + 'OutputPageService', 'OutputRenderService', 'OutputStatusService', 'OutputSlideService', diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 266d1a8c11..5434ab29d9 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -9,6 +9,7 @@ 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 PageService from '~features/output/page.service'; import SlideService from '~features/output/slide.service'; import LegacyRedirect from '~features/output/legacy.route'; @@ -108,11 +109,6 @@ function resolveResource ( status: `${WS_PREFIX}-${name}`, summary: `${WS_PREFIX}-${name}-summary`, }, - page: { - cache: PAGE_CACHE, - size: PAGE_SIZE, - pageLimit: PAGE_LIMIT - } })); if (!handleErrors) { @@ -250,6 +246,7 @@ angular .service('OutputStatusService', StatusService) .service('OutputMessageService', MessageService) .service('JobEventsApiService', EventsApiService) + .service('OutputPageService', PageService) .service('OutputSlideService', SlideService) .component('atJobSearch', SearchComponent) .component('atJobStats', StatsComponent) diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js new file mode 100644 index 0000000000..5acf17c6be --- /dev/null +++ b/awx/ui/client/features/output/page.service.js @@ -0,0 +1,239 @@ +/* eslint camelcase: 0 */ +const PAGE_LIMIT = 5; + +function PageService ($q) { + this.init = (storage, api, { getScrollHeight }) => { + const { prepend, append, shift, pop, deleteRecord } = storage; + const { getPage, getFirst, getLast, getLastPageNumber } = api; + + this.api = { + getPage, + getFirst, + getLast, + getLastPageNumber, + }; + + this.storage = { + prepend, + append, + shift, + pop, + deleteRecord, + }; + + this.hooks = { + getScrollHeight, + }; + + this.records = {}; + this.uuids = {}; + this.state = { + head: 0, + tail: 0, + }; + + this.chain = $q.resolve(); + }; + + this.pushFront = results => { + if (!results) { + return $q.resolve(); + } + + return this.storage.append(results) + .then(() => { + this.records[++this.state.tail] = {}; + results.forEach(({ counter, start_line, end_line, uuid }) => { + this.records[this.state.tail][counter] = { start_line, end_line }; + this.uuids[counter] = uuid; + }); + + return $q.resolve(); + }); + }; + + this.pushBack = results => { + if (!results) { + return $q.resolve(); + } + + return this.storage.prepend(results) + .then(() => { + this.records[--this.state.head] = {}; + results.forEach(({ counter, start_line, end_line, uuid }) => { + this.records[this.state.head][counter] = { start_line, end_line }; + this.uuids[counter] = uuid; + }); + + return $q.resolve(); + }); + }; + + this.popBack = () => { + if (this.getRecordCount() === 0) { + return $q.resolve(); + } + + const pageRecord = this.records[this.state.head] || {}; + + let lines = 0; + const counters = []; + + Object.keys(pageRecord) + .forEach(counter => { + lines += pageRecord[counter].end_line - pageRecord[counter].start_line; + counters.push(counter); + }); + + return this.storage.shift(lines) + .then(() => { + counters.forEach(counter => { + this.storage.deleteRecord(this.uuids[counter]); + delete this.uuids[counter]; + }); + + delete this.records[this.state.head++]; + + return $q.resolve(); + }); + }; + + this.popFront = () => { + if (this.getRecordCount() === 0) { + return $q.resolve(); + } + + const pageRecord = this.records[this.state.tail] || {}; + + let lines = 0; + const counters = []; + + Object.keys(pageRecord) + .forEach(counter => { + lines += pageRecord[counter].end_line - pageRecord[counter].start_line; + counters.push(counter); + }); + + return this.storage.pop(lines) + .then(() => { + counters.forEach(counter => { + this.storage.deleteRecord(this.uuids[counter]); + delete this.uuids[counter]; + }); + + delete this.records[this.state.tail--]; + + return $q.resolve(); + }); + }; + + this.getNext = () => { + const lastPageNumber = this.api.getLastPageNumber(); + const number = Math.min(this.state.tail + 1, lastPageNumber); + + const isLoaded = (number >= this.state.head && number <= this.state.tail); + const isValid = (number >= 1 && number <= lastPageNumber); + + let popHeight = this.hooks.getScrollHeight(); + + if (!isValid || isLoaded) { + this.chain = this.chain + .then(() => $q.resolve(popHeight)); + + return this.chain; + } + + const pageCount = this.state.head - this.state.tail; + + if (pageCount >= PAGE_LIMIT) { + this.chain = this.chain + .then(() => this.popBack()) + .then(() => { + popHeight = this.hooks.getScrollHeight(); + + return $q.resolve(); + }); + } + + this.chain = this.chain + .then(() => this.api.getPage(number)) + .then(events => this.pushFront(events)) + .then(() => $q.resolve(popHeight)); + + return this.chain; + }; + + this.getPrevious = () => { + const number = Math.max(this.state.head - 1, 1); + + const isLoaded = (number >= this.state.head && number <= this.state.tail); + const isValid = (number >= 1 && number <= this.api.getLastPageNumber()); + + let popHeight = this.hooks.getScrollHeight(); + + if (!isValid || isLoaded) { + this.chain = this.chain + .then(() => $q.resolve(popHeight)); + + return this.chain; + } + + const pageCount = this.state.head - this.state.tail; + + if (pageCount >= PAGE_LIMIT) { + this.chain = this.chain + .then(() => this.popFront()) + .then(() => { + popHeight = this.hooks.getScrollHeight(); + + return $q.resolve(); + }); + } + + this.chain = this.chain + .then(() => this.api.getPage(number)) + .then(events => this.pushBack(events)) + .then(() => $q.resolve(popHeight)); + + return this.chain; + }; + + this.clear = () => { + const count = this.getRecordCount(); + + for (let i = 0; i <= count; ++i) { + this.chain = this.chain.then(() => this.popBack()); + } + + return this.chain; + }; + + this.getLast = () => this.clear() + .then(() => this.api.getLast()) + .then(events => { + const lastPage = this.api.getLastPageNumber(); + + this.state.head = lastPage; + this.state.tail = lastPage; + + return this.pushBack(events); + }) + .then(() => this.getPrevious()); + + this.getFirst = () => this.clear() + .then(() => this.api.getFirst()) + .then(events => { + this.state.head = 1; + this.state.tail = 1; + + return this.pushBack(events); + }) + .then(() => this.getNext()); + + this.getRecordCount = () => Object.keys(this.records).length; + this.getTailCounter = () => this.state.tail; +} + +PageService.$inject = ['$q']; + +export default PageService; diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index 73ff8c9a1f..f8ba1f3111 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -77,7 +77,6 @@ function submitSearch () { } const searchInputQueryset = qs.getSearchInputQueryset(vm.value, isFilterable); - const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); reloadQueryset(modifiedQueryset, strings.get('search.REJECT_INVALID')); diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js index 7db6f7dbb7..16551083b7 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -58,6 +58,23 @@ function getOverlapArray (range, other) { return [range[0] - other[0], other[1] - range[1]]; } +/** + * 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])]; +} + function SlidingWindowService ($q) { this.init = (storage, api, { getScrollHeight }) => { const { prepend, append, shift, pop, deleteRecord } = storage; @@ -67,7 +84,7 @@ function SlidingWindowService ($q) { getMaxCounter, getRange, getFirst, - getLast + getLast, }; this.storage = { @@ -85,6 +102,8 @@ function SlidingWindowService ($q) { this.records = {}; this.uuids = {}; this.chain = $q.resolve(); + + api.clearCache(); }; this.pushFront = events => { @@ -176,47 +195,54 @@ function SlidingWindowService ($q) { }); }; - 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]); + const bounds = [1, this.getMaxCounter()]; + const [newHead, newTail] = getBoundedRange([low, high], bounds); + + let popHeight = this.hooks.getScrollHeight(); if (newHead > newTail) { - return $q.resolve([0, 0]); + this.chain = this.chain + .then(() => $q.resolve(popHeight)); + + return this.chain; } if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) { - return $q.resolve([0, 0]); + 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.popBack(this.getRecordCount())) + .then(() => this.clear()) .then(() => this.api.getRange([newHead, newTail])) .then(events => this.pushFront(events)); } if (overlap && overlap[0] < 0) { - this.chain = this.chain.then(() => this.popBack(Math.abs(overlap[0]))); + const popBackCount = Math.abs(overlap[0]); + + this.chain = this.chain.then(() => this.popBack(popBackCount)); } if (overlap && overlap[1] < 0) { - this.chain = this.chain.then(() => this.popFront(Math.abs(overlap[1]))); + const popFrontCount = Math.abs(overlap[1]); + + this.chain = this.chain.then(() => this.popFront(popFrontCount)); } - let popHeight; - this.chain = this.chain.then(() => { - popHeight = this.hooks.getScrollHeight(); + this.chain = this.chain + .then(() => { + popHeight = this.hooks.getScrollHeight(); - return $q.resolve(); - }); + return $q.resolve(); + }); if (overlap && overlap[0] > 0) { const pushBackRange = [head - overlap[0], head]; @@ -240,7 +266,7 @@ function SlidingWindowService ($q) { return this.chain; }; - this.slideDown = (displacement = PAGE_SIZE) => { + this.getNext = (displacement = PAGE_SIZE) => { const [head, tail] = this.getRange(); const tailRoom = this.getMaxCounter() - tail; @@ -257,7 +283,7 @@ function SlidingWindowService ($q) { return this.move([head + headDisplacement, tail + tailDisplacement]); }; - this.slideUp = (displacement = PAGE_SIZE) => { + this.getPrevious = (displacement = PAGE_SIZE) => { const [head, tail] = this.getRange(); const headRoom = head - 1; diff --git a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js index 561664185b..e27d1b130e 100644 --- a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js +++ b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js @@ -23,6 +23,13 @@ function atRelaunchCtrl ( const jobObj = new Job(); const jobTemplate = new JobTemplate(); + const transitionOptions = { reload: true }; + + if ($state.includes('output')) { + transitionOptions.inherit = false; + transitionOptions.location = 'replace'; + } + const updateTooltip = () => { if (vm.job.type === 'job' && vm.job.status === 'failed') { vm.tooltip = strings.get('relaunch.HOSTS'); @@ -128,7 +135,7 @@ function atRelaunchCtrl ( .then((launchRes) => { if (!$state.is('jobs')) { const relaunchType = launchRes.data.type === 'job' ? 'playbook' : launchRes.data.type; - $state.go('output', { id: launchRes.data.id, type: relaunchType }, { reload: true }); + $state.go('output', { id: launchRes.data.id, type: relaunchType }, transitionOptions); } }).catch(({ data, status, config }) => { ProcessErrors($scope, data, status, null, { @@ -173,7 +180,7 @@ function atRelaunchCtrl ( inventorySource.postUpdate(vm.job.inventory_source) .then((postUpdateRes) => { if (!$state.is('jobs')) { - $state.go('output', { id: postUpdateRes.data.id, type: 'inventory' }, { reload: true }); + $state.go('output', { id: postUpdateRes.data.id, type: 'inventory' }, transitionOptions); } }).catch(({ data, status, config }) => { ProcessErrors($scope, data, status, null, { @@ -197,7 +204,7 @@ function atRelaunchCtrl ( project.postUpdate(vm.job.project) .then((postUpdateRes) => { if (!$state.is('jobs')) { - $state.go('output', { id: postUpdateRes.data.id, type: 'project' }, { reload: true }); + $state.go('output', { id: postUpdateRes.data.id, type: 'project' }, transitionOptions); } }).catch(({ data, status, config }) => { ProcessErrors($scope, data, status, null, { @@ -219,7 +226,7 @@ function atRelaunchCtrl ( id: vm.job.id }).then((launchRes) => { if (!$state.is('jobs')) { - $state.go('workflowResults', { id: launchRes.data.id }, { reload: true }); + $state.go('workflowResults', { id: launchRes.data.id }, transitionOptions); } }).catch(({ data, status, config }) => { ProcessErrors($scope, data, status, null, { @@ -243,7 +250,7 @@ function atRelaunchCtrl ( id: vm.job.id }).then((launchRes) => { if (!$state.is('jobs')) { - $state.go('output', { id: launchRes.data.id, type: 'command' }, { reload: true }); + $state.go('output', { id: launchRes.data.id, type: 'command' }, transitionOptions); } }).catch(({ data, status, config }) => { ProcessErrors($scope, data, status, null, { @@ -268,7 +275,7 @@ function atRelaunchCtrl ( relaunchData: PromptService.bundlePromptDataForRelaunch(vm.promptData) }).then((launchRes) => { if (!$state.is('jobs')) { - $state.go('output', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); + $state.go('output', { id: launchRes.data.job, type: 'playbook' }, transitionOptions); } }).catch(({ data, status }) => { ProcessErrors($scope, data, status, null, {