diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index 67224f387a..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; diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index 3a4e033cfa..9da4f34ba8 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -1,10 +1,12 @@ -const API_PAGE_SIZE = 200; -const PAGE_SIZE = 50; -const ORDER_BY = 'counter'; +import { + API_MAX_PAGE_SIZE, + OUTPUT_ORDER_BY, + OUTPUT_PAGE_SIZE, +} from './constants'; const BASE_PARAMS = { - page_size: PAGE_SIZE, - order_by: ORDER_BY, + page_size: OUTPUT_PAGE_SIZE, + order_by: OUTPUT_ORDER_BY, }; const merge = (...objs) => _.merge({}, ...objs); @@ -18,12 +20,6 @@ function JobEventsApiService ($http, $q) { this.cache = {}; }; - this.clearCache = () => { - Object.keys(this.cache).forEach(key => { - delete this.cache[key]; - }); - }; - this.fetch = () => this.getLast() .then(results => { this.cache.last = results; @@ -31,20 +27,31 @@ function JobEventsApiService ($http, $q) { return this; }); + this.clearCache = () => { + Object.keys(this.cache).forEach(key => { + delete this.cache[key]; + }); + }; + + this.pushMaxCounter = events => { + const maxCounter = Math.max(...events.map(({ counter }) => counter)); + + if (maxCounter > this.state.maxCounter) { + this.state.maxCounter = maxCounter; + } + + return maxCounter; + }; + this.getFirst = () => { - const page = 1; - const params = merge(this.params, { page }); + const params = merge(this.params, { page: 1 }); 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; - } + this.pushMaxCounter(results); return results; }); @@ -60,13 +67,9 @@ 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; - - if (maxCounter > this.state.maxCounter) { - this.state.maxCounter = maxCounter; - } + this.pushMaxCounter(results); return results; }); @@ -77,17 +80,16 @@ function JobEventsApiService ($http, $q) { return $q.resolve(this.cache.last); } - const params = merge(this.params, { page: 1, order_by: `-${ORDER_BY}` }); + const params = merge(this.params, { page: 1, order_by: `-${OUTPUT_ORDER_BY}` }); return $http.get(this.endpoint, { params }) .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 (count > OUTPUT_PAGE_SIZE) { + rotated = results.splice(count % OUTPUT_PAGE_SIZE); if (results.length > 0) { rotated = results; @@ -95,10 +97,7 @@ function JobEventsApiService ($http, $q) { } this.state.count = count; - - if (maxCounter > this.state.maxCounter) { - this.state.maxCounter = maxCounter; - } + this.pushMaxCounter(results); return rotated; }); @@ -110,24 +109,26 @@ 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_PAGE_SIZE; + params.page_size = API_MAX_PAGE_SIZE; return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results } = data; - const maxCounter = Math.max(...results.map(({ counter }) => counter)); - if (maxCounter > this.state.maxCounter) { - this.state.maxCounter = maxCounter; - } + this.pushMaxCounter(results); return results; }); }; - this.getLastPageNumber = () => Math.ceil(this.state.count / PAGE_SIZE); + this.getLastPageNumber = () => Math.ceil(this.state.count / OUTPUT_PAGE_SIZE); this.getMaxCounter = () => this.state.maxCounter; } diff --git a/awx/ui/client/features/output/constants.js b/awx/ui/client/features/output/constants.js new file mode 100644 index 0000000000..4bab4ca1bb --- /dev/null +++ b/awx/ui/client/features/output/constants.js @@ -0,0 +1,30 @@ +export const API_MAX_PAGE_SIZE = 200; +export const API_ROOT = '/api/v2/'; + +export const EVENT_START_TASK = 'playbook_on_task_start'; +export const EVENT_START_PLAY = 'playbook_on_play_start'; +export const EVENT_START_PLAYBOOK = 'playbook_on_start'; +export const EVENT_STATS_PLAY = 'playbook_on_stats'; + +export const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped']; + +export const JOB_STATUS_COMPLETE = ['successful', 'failed', 'unknown']; +export const JOB_STATUS_INCOMPLETE = ['canceled', 'error']; +export const JOB_STATUS_UNSUCCESSFUL = ['failed'].concat(JOB_STATUS_INCOMPLETE); +export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPLETE); + +export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container'; +export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable'; +export const OUTPUT_MAX_LAG = 120; +export const OUTPUT_ORDER_BY = 'counter'; +export const OUTPUT_PAGE_CACHE = true; +export const OUTPUT_PAGE_LIMIT = 5; +export const OUTPUT_PAGE_SIZE = 50; +export const OUTPUT_SCROLL_DELAY = 100; +export const OUTPUT_SCROLL_THRESHOLD = 0.1; +export const OUTPUT_SEARCH_DOCLINK = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html'; +export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; +export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01']; +export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE; + +export const WS_PREFIX = 'ws'; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 5039e83ff1..88d86ca91b 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,6 +1,9 @@ /* eslint camelcase: 0 */ -const EVENT_START_TASK = 'playbook_on_task_start'; -const EVENT_START_PLAY = 'playbook_on_play_start'; +import { + EVENT_START_PLAY, + EVENT_START_TASK, + OUTPUT_PAGE_SIZE, +} from './constants'; let $compile; let $q; @@ -20,9 +23,6 @@ const bufferState = [0, 0]; // [length, count] const listeners = []; const rx = []; -let following = false; -let attach = true; - function bufferInit () { rx.length = 0; @@ -55,57 +55,109 @@ function bufferEmpty (min, max) { return removed; } +let lockFrames; 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(); - } - - if (!attach) { - return $q.resolve(); - } - - follow(); + if (lockFrames) { + events.forEach(bufferAdd); + return $q.resolve(); } - const capacity = slide.getCapacity(); + events = slide.pushFrames(events); + const popCount = events.length - slide.getCapacity(); + const isAttached = events.length > 0; - return slide.popBack(events.length - capacity) - .then(() => slide.pushFront(events)) - .then(() => { - scroll.setScrollPosition(scroll.getScrollHeight()); + if (!isAttached) { + stopFollowing(); + return $q.resolve(); + } - return $q.resolve(); - }); -} + if (!vm.isFollowing && canStartFollowing()) { + startFollowing(); + } + + if (!vm.isFollowing && popCount > 0) { + return $q.resolve(); + } -function first () { - unfollow(); scroll.pause(); - return slide.getFirst() + if (vm.isFollowing) { + scroll.scrollToBottom(); + } + + return slide.popBack(popCount) .then(() => { + if (vm.isFollowing) { + scroll.scrollToBottom(); + } + + return slide.pushFront(events); + }) + .then(() => { + if (vm.isFollowing) { + scroll.scrollToBottom(); + } + scroll.resume(); return $q.resolve(); }); } -function next () { +function first () { + if (scroll.isPaused()) { + return $q.resolve(); + } + scroll.pause(); + lockFrames = true; + + stopFollowing(); + + return slide.getFirst() + .then(() => { + scroll.resetScrollPosition(); + }) + .finally(() => { + scroll.resume(); + lockFrames = false; + }); +} + +function next () { + if (vm.isFollowing) { + scroll.scrollToBottom(); + + return $q.resolve(); + } + + if (scroll.isPaused()) { + return $q.resolve(); + } + + if (slide.getTailCounter() >= slide.getMaxCounter()) { + return $q.resolve(); + } + + scroll.pause(); + lockFrames = true; return slide.getNext() - .finally(() => scroll.resume()); + .finally(() => { + scroll.resume(); + lockFrames = false; + }); } function previous () { - unfollow(); + if (scroll.isPaused()) { + return $q.resolve(); + } + scroll.pause(); + lockFrames = true; + + stopFollowing(); const initialPosition = scroll.getScrollPosition(); @@ -116,51 +168,101 @@ 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.setScrollPosition(scroll.getScrollHeight()); - - attach = true; - scroll.resume(); + 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) { + lockFollow = true; + stopFollowing(); + + return $q.resolve(); + } + + lockFollow = false; + + if (slide.isOnLastPage()) { + scroll.scrollToBottom(); + + return $q.resolve(); + } + + return last(); +} + function down () { scroll.moveDown(); } function up () { - if (following) { - unfollow(); - } else { - scroll.moveUp(); - } -} - -function follow () { - scroll.pause(); - scroll.hide(); - - following = true; - vm.isFollowing = following; -} - -function unfollow () { - attach = false; - following = false; - vm.isFollowing = following; - - scroll.unhide(); - scroll.resume(); + scroll.moveUp(); } function togglePanelExpand () { @@ -235,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; } @@ -252,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) { @@ -316,37 +454,62 @@ function OutputIndexController ( vm.togglePanelExpand = togglePanelExpand; // Stdout Navigation - vm.menu = { last, first, down, up }; + vm.menu = { last: menuLast, first, down, up }; vm.isMenuExpanded = true; - vm.isFollowing = following; + vm.isFollowing = 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); - render.init({ compile, toggles: vm.toggleLineEnabled }); - scroll.init({ previous, next }); + + scroll.init({ + next, + previous, + onThresholdLeave () { + followOnce = false; + lockFollow = false; + stopFollowing(); + + return $q.resolve(); + }, + }); stream.init({ bufferAdd, 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 70ce3f2572..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'; @@ -18,16 +19,15 @@ import SearchComponent from '~features/output/search.component'; import StatsComponent from '~features/output/stats.component'; import HostEvent from './host-event/index'; -const Template = require('~features/output/index.view.html'); +import { + API_ROOT, + OUTPUT_ORDER_BY, + OUTPUT_PAGE_SIZE, + WS_PREFIX, +} from './constants'; const MODULE_NAME = 'at.features.output'; - -const PAGE_CACHE = true; -const PAGE_LIMIT = 5; -const PAGE_SIZE = 50; -const ORDER_BY = 'counter'; -const WS_PREFIX = 'ws'; -const API_ROOT = '/api/v2/'; +const Template = require('~features/output/index.view.html'); function resolveResource ( $state, @@ -42,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; @@ -80,23 +78,16 @@ function resolveResource ( } const params = { - page_size: PAGE_SIZE, - order_by: ORDER_BY, - }; - - const config = { - params, - pageCache: PAGE_CACHE, - pageLimit: PAGE_LIMIT, + page_size: OUTPUT_PAGE_SIZE, + order_by: OUTPUT_ORDER_BY, }; if (job_event_search) { // eslint-disable-line camelcase const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); - - Object.assign(config.params, query); + Object.assign(params, query); } - Events.init(`${API_ROOT}${related}`, config.params); + Events.init(`${API_ROOT}${related}`, params); Wait('start'); const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()]) diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index c588f2b375..08df5f714a 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -7,56 +7,52 @@ -
-
- - {{ vm.title }} +
+
+ + {{ vm.title }} +
+ + + + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+
+
- - - - -
-
- -
- -
- -
-
- -
-
- -
-
- -
- -
-
- -
-
-
-
-
- -
-
-

-

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

-
- -
-
-
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 0b14b36d8d..786d26ad66 100644 --- a/awx/ui/client/features/output/page.service.js +++ b/awx/ui/client/features/output/page.service.js @@ -1,16 +1,17 @@ /* eslint camelcase: 0 */ -const PAGE_LIMIT = 5; +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 = { @@ -150,7 +151,7 @@ function PageService ($q) { const pageCount = this.state.head - this.state.tail; - if (pageCount >= PAGE_LIMIT) { + if (pageCount >= OUTPUT_PAGE_LIMIT) { this.chain = this.chain .then(() => this.popBack()) .then(() => { @@ -185,7 +186,7 @@ function PageService ($q) { const pageCount = this.state.head - this.state.tail; - if (pageCount >= PAGE_LIMIT) { + if (pageCount >= OUTPUT_PAGE_LIMIT) { this.chain = this.chain .then(() => this.popFront()) .then(() => { @@ -235,8 +236,10 @@ function PageService ($q) { }) .then(() => this.getNext()); + 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 26b82c728e..a7f44162a7 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -1,20 +1,22 @@ import Ansi from 'ansi-to-html'; import Entities from 'html-entities'; -const ELEMENT_TBODY = '#atStdoutResultTable'; -const EVENT_START_TASK = 'playbook_on_task_start'; -const EVENT_START_PLAY = 'playbook_on_play_start'; -const EVENT_STATS_PLAY = 'playbook_on_stats'; +import { + EVENT_START_PLAY, + EVENT_STATS_PLAY, + EVENT_START_TASK, + OUTPUT_ELEMENT_TBODY, +} from './constants'; const EVENT_GROUPS = [ EVENT_START_TASK, - EVENT_START_PLAY + EVENT_START_PLAY, ]; const TIME_EVENTS = [ EVENT_START_TASK, EVENT_START_PLAY, - EVENT_STATS_PLAY + EVENT_STATS_PLAY, ]; const ansi = new Ansi(); @@ -33,7 +35,7 @@ function JobRenderService ($q, $sce, $window) { this.init = ({ compile, toggles }) => { this.parent = null; this.record = {}; - this.el = $(ELEMENT_TBODY); + this.el = $(OUTPUT_ELEMENT_TBODY); this.hooks = { compile }; this.createToggles = toggles; @@ -67,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 }; } @@ -125,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), }; @@ -165,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 1cd5887f25..4e6e2eff57 100644 --- a/awx/ui/client/features/output/scroll.service.js +++ b/awx/ui/client/features/output/scroll.service.js @@ -1,11 +1,16 @@ -const ELEMENT_CONTAINER = '.at-Stdout-container'; -const ELEMENT_TBODY = '#atStdoutResultTable'; -const DELAY = 100; -const THRESHOLD = 0.1; +import { + OUTPUT_ELEMENT_CONTAINER, + OUTPUT_ELEMENT_TBODY, + OUTPUT_SCROLL_DELAY, + OUTPUT_SCROLL_THRESHOLD, +} from './constants'; + +const MAX_THRASH = 20; function JobScrollService ($q, $timeout) { - this.init = ({ next, previous }) => { - this.el = $(ELEMENT_CONTAINER); + this.init = ({ next, previous, onThresholdLeave }) => { + this.el = $(OUTPUT_ELEMENT_CONTAINER); + this.chain = $q.resolve(); this.timer = null; this.position = { @@ -13,19 +18,43 @@ function JobScrollService ($q, $timeout) { current: 0 }; + this.threshold = { + previous: 0, + current: 0, + }; + this.hooks = { next, previous, - isAtRest: () => $q.resolve() + onThresholdLeave, }; this.state = { - hidden: false, paused: false, - top: true, + locked: false, + hover: false, + running: true, + thrash: 0, }; 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 = () => { @@ -33,77 +62,105 @@ 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); } - this.timer = $timeout(this.register, DELAY); + this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY); }; this.register = () => { - this.pause(); + const position = this.getScrollPosition(); + const viewport = this.getScrollHeight() - this.getViewableHeight(); - const current = this.getScrollPosition(); - const downward = current > this.position.previous; + const threshold = position / viewport; + const downward = position > this.position.previous; - let promise; + const isBeyondUpperThreshold = threshold < OUTPUT_SCROLL_THRESHOLD; + const isBeyondLowerThreshold = (1 - threshold) < OUTPUT_SCROLL_THRESHOLD; - if (downward && this.isBeyondThreshold(downward, current)) { - promise = this.hooks.next; - } else if (!downward && this.isBeyondThreshold(downward, current)) { - promise = this.hooks.previous; + 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 || enteredUpperThreshold) { + transitions.push(this.hooks.onThresholdLeave); + transitions.push(this.hooks.previous); } - if (!promise) { - this.setScrollPosition(current); - this.isAtRest(); - this.resume(); - - return $q.resolve(); + if (leftLowerThreshold) { + transitions.push(this.hooks.onThresholdLeave); } - return promise() + if (threshold >= 1 || enteredLowerThreshold) { + transitions.push(this.hooks.next); + } + + if (!downward) { + transitions.reverse(); + } + + this.position.current = position; + this.threshold.current = threshold; + + transitions.forEach(promise => { + this.chain = this.chain.then(() => promise()); + }); + + return this.chain .then(() => { this.setScrollPosition(this.getScrollPosition()); - this.isAtRest(); - this.resume(); + + return $q.resolve(); }); }; - this.isBeyondThreshold = (downward, current) => { - const height = this.getScrollHeight(); - - if (downward) { - current += this.getViewableHeight(); - - if (current >= height || ((height - current) / height) < THRESHOLD) { - return true; - } - } else if (current <= 0 || (current / height) < THRESHOLD) { - return true; - } - - return false; - }; - /** * Move scroll position up by one page of visible content. */ this.moveUp = () => { - const top = this.getScrollPosition(); - const height = this.getViewableHeight(); + const position = this.getScrollPosition() - this.getViewableHeight(); - this.setScrollPosition(top - height); + this.setScrollPosition(position); }; /** * Move scroll position down by one page of visible content. */ this.moveDown = () => { - const top = this.getScrollPosition(); - const height = this.getViewableHeight(); + const position = this.getScrollPosition() + this.getViewableHeight(); - this.setScrollPosition(top + height); + this.setScrollPosition(position); }; this.getScrollHeight = () => this.el[0].scrollHeight; @@ -117,43 +174,37 @@ function JobScrollService ($q, $timeout) { this.getScrollPosition = () => this.el[0].scrollTop; this.setScrollPosition = position => { + const viewport = this.getScrollHeight() - this.getViewableHeight(); + this.position.previous = this.position.current; + this.threshold.previous = this.position.previous / viewport; this.position.current = position; + this.el[0].scrollTop = position; - this.isAtRest(); }; this.resetScrollPosition = () => { + this.threshold.previous = 0; this.position.previous = 0; this.position.current = 0; + this.el[0].scrollTop = 0; - this.isAtRest(); }; this.scrollToBottom = () => { this.setScrollPosition(this.getScrollHeight()); }; - this.isAtRest = () => { - if (this.position.current === 0 && !this.state.top) { - this.state.top = true; - this.hooks.isAtRest(true); - } else if (this.position.current > 0 && this.state.top) { - this.state.top = false; - this.hooks.isAtRest(false); - } + this.start = () => { + this.state.running = true; }; - this.resume = () => { - this.state.paused = false; + this.stop = () => { + this.unlock(); + this.unhide(); + this.state.running = false; }; - this.pause = () => { - this.state.paused = true; - }; - - this.isPaused = () => this.state.paused; - this.lock = () => { this.state.locked = true; }; @@ -162,22 +213,52 @@ function JobScrollService ($q, $timeout) { this.state.locked = false; }; + this.pause = () => { + this.state.paused = true; + }; + + this.resume = () => { + this.state.paused = false; + }; + this.hide = () => { - if (!this.state.hidden) { - this.el.css('overflow', 'hidden'); - this.state.hidden = true; + if (this.state.hidden) { + return; } + + this.state.hidden = true; + this.el.css('overflow-y', 'hidden'); }; this.unhide = () => { - if (this.state.hidden) { - this.el.css('overflow', 'auto'); - this.state.hidden = false; + 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 = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); + this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); } JobScrollService.$inject = ['$q', '$timeout']; diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index f8ba1f3111..09ef3e52f2 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -1,8 +1,10 @@ -const templateUrl = require('~features/output/search.partial.html'); +import { + OUTPUT_SEARCH_DOCLINK, + OUTPUT_SEARCH_FIELDS, + OUTPUT_SEARCH_KEY_EXAMPLES, +} from './constants'; -const searchKeyExamples = ['host_name:localhost', 'task:set', 'created:>=2000-01-01']; -const searchKeyFields = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; -const searchKeyDocLink = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html'; +const templateUrl = require('~features/output/search.partial.html'); let $state; let qs; @@ -50,7 +52,7 @@ function reloadQueryset (queryset, rejection = strings.get('search.REJECT_DEFAUL const isFilterable = term => { const field = term[0].split('.')[0].replace(/^-/, ''); - return (searchKeyFields.indexOf(field) > -1); + return (OUTPUT_SEARCH_FIELDS.indexOf(field) > -1); }; function removeSearchTag (index) { @@ -94,9 +96,9 @@ function JobSearchController (_$state_, _qs_, _strings_, { subscribe }) { vm = this || {}; vm.strings = strings; - vm.examples = searchKeyExamples; - vm.fields = searchKeyFields; - vm.docLink = searchKeyDocLink; + vm.examples = OUTPUT_SEARCH_KEY_EXAMPLES; + vm.fields = OUTPUT_SEARCH_FIELDS; + vm.docLink = OUTPUT_SEARCH_DOCLINK; vm.relatedFields = []; vm.clearSearch = clearSearch; diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js index 16551083b7..8bddc51565 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -1,97 +1,57 @@ /* eslint camelcase: 0 */ -const PAGE_SIZE = 50; -const PAGE_LIMIT = 5; -const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE; +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 }) => { - const { prepend, append, shift, pop, deleteRecord } = storage; - const { getMaxCounter, getRange, getFirst, getLast } = api; + const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage; + const { getRange, getFirst, getLast, getMaxCounter } = api; this.api = { - getMaxCounter, getRange, getFirst, getLast, + getMaxCounter, }; this.storage = { + clear, prepend, append, shift, pop, + getRecord, deleteRecord, }; @@ -99,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 => { @@ -112,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(); }); @@ -128,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(); }); @@ -148,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(); @@ -177,190 +195,220 @@ 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; - } + return $q.resolve(); + }); + this.getNext = (displacement = OUTPUT_PAGE_SIZE) => { + const next = this.getNextRange(displacement); const [head, tail] = this.getRange(); - const overlap = getOverlapArray([head, tail], [newHead, newTail]); - - if (!overlap) { - this.chain = this.chain - .then(() => this.clear()) - .then(() => this.api.getRange([newHead, newTail])) - .then(events => this.pushFront(events)); - } - - 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(); + .then(() => this.api.getRange(next)) + .then(events => { + const results = getContinuous(events); + const min = Math.min(...results.map(({ counter }) => counter)); - return $q.resolve(); + if (min > tail + 1) { + return $q.resolve([]); + } + + return $q.resolve(results); + }) + .then(results => { + const count = (tail - head + results.length); + const excess = count - OUTPUT_EVENT_LIMIT; + + return this.popBack(excess) + .then(() => { + const popHeight = this.hooks.getScrollHeight(); + + return this.pushFront(results).then(() => $q.resolve(popHeight)); + }); }); - if (overlap && overlap[0] > 0) { - const pushBackRange = [head - overlap[0], head]; + return this.chain; + }; - 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.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => { + const previous = this.getPreviousRange(displacement); + const [head, tail] = this.getRange(); this.chain = this.chain - .then(() => $q.resolve(popHeight)); + .then(() => this.api.getRange(previous)) + .then(events => { + const results = getContinuous(events, true); + const max = Math.max(...results.map(({ counter }) => counter)); + + if (head > max + 1) { + return $q.resolve([]); + } + + return $q.resolve(results); + }) + .then(results => { + const count = (tail - head + results.length); + const excess = count - OUTPUT_EVENT_LIMIT; + + return this.popFront(excess) + .then(() => { + const popHeight = this.hooks.getScrollHeight(); + + return this.pushBack(results).then(() => $q.resolve(popHeight)); + }); + }); return this.chain; }; - this.getNext = (displacement = PAGE_SIZE) => { - const [head, tail] = this.getRange(); + this.getFirst = () => { + this.chain = this.chain + .then(() => this.clear()) + .then(() => { + if (this.cache.first) { + return $q.resolve(this.cache.first); + } - const tailRoom = this.getMaxCounter() - tail; - const tailDisplacement = Math.min(tailRoom, displacement); + return this.api.getFirst(); + }) + .then(events => { + if (events.length === OUTPUT_PAGE_SIZE) { + this.cache.first = events; + } - const newTail = tail + tailDisplacement; + return this.pushFront(events); + }); - let headDisplacement = 0; - - if (newTail - head > EVENT_LIMIT) { - headDisplacement = (newTail - EVENT_LIMIT) - head; - } - - return this.move([head + headDisplacement, tail + tailDisplacement]); + return this.chain + .then(() => this.getNext()); }; - this.getPrevious = (displacement = PAGE_SIZE) => { - const [head, tail] = this.getRange(); + this.getLast = () => { + this.chain = this.chain + .then(() => this.getFrames()) + .then(frames => { + if (frames.length > 0) { + return $q.resolve(frames); + } - const headRoom = head - 1; - const headDisplacement = Math.min(headRoom, displacement); + return this.api.getLast(); + }) + .then(events => { + const min = Math.min(...events.map(({ counter }) => counter)); - const newHead = head - headDisplacement; + if (min <= this.getTailCounter() + 1) { + return this.pushFront(events); + } - let tailDisplacement = 0; + return this.clear() + .then(() => this.pushBack(events)); + }); - if (tail - newHead > EVENT_LIMIT) { - tailDisplacement = tail - (newHead + EVENT_LIMIT); - } - - return this.move([newHead, tail - tailDisplacement]); + return this.chain + .then(() => this.getPrevious()); }; - this.moveHead = displacement => { - const [head, tail] = this.getRange(); - - const headRoom = head - 1; - const headDisplacement = Math.min(headRoom, displacement); - - return this.move([head + headDisplacement, tail]); - }; - - this.moveTail = displacement => { - const [head, tail] = this.getRange(); - - const tailRoom = this.getMaxCounter() - tail; - const tailDisplacement = Math.max(tailRoom, displacement); - - return this.move([head, tail + tailDisplacement]); - }; - - this.clear = () => { - const count = this.getRecordCount(); - - if (count > 0) { - this.chain = this.chain - .then(() => this.popBack(count)); - } - - return this.chain; - }; - - this.getFirst = () => this.clear() - .then(() => this.api.getFirst()) - .then(events => this.pushFront(events)) - .then(() => this.moveTail(PAGE_SIZE)); - - this.getLast = () => this.clear() - .then(() => this.api.getLast()) - .then(events => this.pushBack(events)) - .then(() => this.moveHead(-PAGE_SIZE)); - this.getTailCounter = () => { - 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 = () => { - const counter = this.api.getMaxCounter(); - const tail = this.getTailCounter(); + if (this.buffer.min) { + return this.buffer.min; + } - return Number.isFinite(counter) ? Math.max(tail, counter) : tail; + return this.api.getMaxCounter(); }; + this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE); this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; - this.getRecordCount = () => Object.keys(this.records).length; - this.getCapacity = () => EVENT_LIMIT - this.getRecordCount(); + this.getRecordCount = () => Object.keys(this.lines).length; + this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount(); } SlidingWindowService.$inject = ['$q']; diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index a8b5d6ee8e..26483ff0e2 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -1,20 +1,22 @@ /* eslint camelcase: 0 */ -const JOB_START = 'playbook_on_start'; -const JOB_END = 'playbook_on_stats'; -const PLAY_START = 'playbook_on_play_start'; -const TASK_START = 'playbook_on_task_start'; - -const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped']; -const COMPLETE = ['successful', 'failed', 'unknown']; -const INCOMPLETE = ['canceled', 'error']; -const UNSUCCESSFUL = ['failed'].concat(INCOMPLETE); -const FINISHED = COMPLETE.concat(INCOMPLETE); +import { + EVENT_START_PLAYBOOK, + EVENT_STATS_PLAY, + EVENT_START_PLAY, + EVENT_START_TASK, + HOST_STATUS_KEYS, + JOB_STATUS_COMPLETE, + JOB_STATUS_INCOMPLETE, + JOB_STATUS_UNSUCCESSFUL, + JOB_STATUS_FINISHED, +} from './constants'; function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); 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'); @@ -43,6 +45,14 @@ function JobStatusService (moment, message) { }, }; + this.initHostStatusCounts({ model }); + this.initPlaybookCounts({ model }); + + this.updateRunningState(); + this.dispatch(); + }; + + this.initHostStatusCounts = ({ model }) => { if (model.has('host_status_counts')) { this.setHostStatusCounts(model.get('host_status_counts')); } else { @@ -50,23 +60,22 @@ 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 => { - if (UNSUCCESSFUL.includes(status)) { + if (JOB_STATUS_UNSUCCESSFUL.includes(status)) { return { failures: 1 }; } - if (COMPLETE.includes(status)) { + if (JOB_STATUS_COMPLETE.includes(status)) { return { ok: 1 }; } @@ -92,7 +101,7 @@ function JobStatusService (moment, message) { let changed = false; - if (!this.active && !(data.event === JOB_END)) { + if (!this.active && !(data.event === EVENT_STATS_PLAY)) { this.active = true; this.setJobStatus('running'); changed = true; @@ -105,22 +114,22 @@ function JobStatusService (moment, message) { changed = true; } - if (data.event === JOB_START) { + if (data.event === EVENT_START_PLAYBOOK) { this.setStarted(this.state.started || data.created); changed = true; } - if (data.event === PLAY_START) { + if (data.event === EVENT_START_PLAY) { this.state.counts.plays++; changed = true; } - if (data.event === TASK_START) { + if (data.event === EVENT_START_TASK) { this.state.counts.tasks++; changed = true; } - if (data.event === JOB_END) { + if (data.event === EVENT_STATS_PLAY) { this.setStatsEvent(data); changed = true; } @@ -193,17 +202,20 @@ function JobStatusService (moment, message) { this.setJobStatus = status => { const isExpectingStats = this.isExpectingStatsEvent(); - const isIncomplete = INCOMPLETE.includes(status); - const isFinished = FINISHED.includes(status); - const isAlreadyFinished = FINISHED.includes(this.state.status); + const isIncomplete = JOB_STATUS_INCOMPLETE.includes(status); + 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')); @@ -216,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(); }; @@ -233,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; }; @@ -266,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 953c886882..5b14d26b4b 100644 --- a/awx/ui/client/features/output/stream.service.js +++ b/awx/ui/client/features/output/stream.service.js @@ -1,7 +1,9 @@ /* eslint camelcase: 0 */ -const PAGE_SIZE = 50; -const MAX_LAG = 120; -const JOB_END = 'playbook_on_stats'; +import { + EVENT_STATS_PLAY, + OUTPUT_MAX_LAG, + OUTPUT_PAGE_SIZE, +} from './constants'; function OutputStream ($q) { this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => { @@ -22,13 +24,13 @@ function OutputStream ($q) { this.state = { ending: false, - ended: false + ended: false, }; this.lag = 0; this.chain = $q.resolve(); - this.factors = this.calcFactors(PAGE_SIZE); + this.factors = this.calcFactors(OUTPUT_PAGE_SIZE); this.setFramesPerRender(); }; @@ -47,7 +49,7 @@ function OutputStream ($q) { }; this.setFramesPerRender = () => { - const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); + const index = Math.floor((this.lag / OUTPUT_MAX_LAG) * this.factors.length); const boundedIndex = Math.min(this.factors.length - 1, index); this.framesPerRender = this.factors[boundedIndex]; @@ -96,7 +98,7 @@ function OutputStream ($q) { this.chain = this.chain .then(() => { - if (data.event === JOB_END) { + if (data.event === EVENT_STATS_PLAY) { this.state.ending = true; this.counters.final = data.counter; } @@ -104,7 +106,7 @@ function OutputStream ($q) { const [minReady, maxReady] = this.updateCounterState(data); const count = this.hooks.bufferAdd(data); - if (count % PAGE_SIZE === 0) { + if (count % OUTPUT_PAGE_SIZE === 0) { this.setFramesPerRender(); } @@ -158,6 +160,8 @@ function OutputStream ($q) { this.counters.ready.length = 0; return $q.resolve(); }); + + this.getMaxCounter = () => this.counters.max; } OutputStream.$inject = ['$q'];