From 4c3370bd34392a334fcee9a3257a70df5f5afb07 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 30 Jul 2018 18:10:45 -0400 Subject: [PATCH 1/3] move constants to a file --- .../features/output/api.events.service.js | 22 +++++----- awx/ui/client/features/output/constants.js | 30 ++++++++++++++ .../features/output/index.controller.js | 6 ++- awx/ui/client/features/output/index.js | 30 +++++--------- awx/ui/client/features/output/page.service.js | 6 +-- .../client/features/output/render.service.js | 16 ++++---- .../client/features/output/scroll.service.js | 20 +++++---- .../features/output/search.component.js | 18 ++++---- .../client/features/output/slide.service.js | 25 +++++------ .../client/features/output/status.service.js | 41 ++++++++++--------- .../client/features/output/stream.service.js | 16 ++++---- 11 files changed, 133 insertions(+), 97 deletions(-) create mode 100644 awx/ui/client/features/output/constants.js diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index 3a4e033cfa..8db532cc25 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, + order_by: OUTPUT_ORDER_BY, + page_size: OUTPUT_PAGE_SIZE, }; const merge = (...objs) => _.merge({}, ...objs); @@ -77,7 +79,7 @@ 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 }) => { @@ -86,8 +88,8 @@ function JobEventsApiService ($http, $q) { 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; @@ -112,7 +114,7 @@ function JobEventsApiService ($http, $q) { const [low, high] = range; 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 }) => { @@ -127,7 +129,7 @@ function JobEventsApiService ($http, $q) { }); }; - 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..8532bf4416 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,6 +1,8 @@ /* 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, +} from './constants'; let $compile; let $q; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 70ce3f2572..e4e80a3051 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -18,16 +18,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, @@ -80,23 +79,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/page.service.js b/awx/ui/client/features/output/page.service.js index 0b14b36d8d..7ba5e2b88e 100644 --- a/awx/ui/client/features/output/page.service.js +++ b/awx/ui/client/features/output/page.service.js @@ -1,5 +1,5 @@ /* eslint camelcase: 0 */ -const PAGE_LIMIT = 5; +import { OUTPUT_PAGE_LIMIT } from './constants'; function PageService ($q) { this.init = (storage, api, { getScrollHeight }) => { @@ -150,7 +150,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 +185,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(() => { diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index 26b82c728e..88aea55ecd 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; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js index 1cd5887f25..bbe6e91427 100644 --- a/awx/ui/client/features/output/scroll.service.js +++ b/awx/ui/client/features/output/scroll.service.js @@ -1,11 +1,13 @@ -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'; function JobScrollService ($q, $timeout) { this.init = ({ next, previous }) => { - this.el = $(ELEMENT_CONTAINER); + this.el = $(OUTPUT_ELEMENT_CONTAINER); this.timer = null; this.position = { @@ -37,7 +39,7 @@ function JobScrollService ($q, $timeout) { $timeout.cancel(this.timer); } - this.timer = $timeout(this.register, DELAY); + this.timer = $timeout(this.register, OUTPUT_SCROLL_DELAY); }; this.register = () => { @@ -76,10 +78,10 @@ function JobScrollService ($q, $timeout) { if (downward) { current += this.getViewableHeight(); - if (current >= height || ((height - current) / height) < THRESHOLD) { + if (current >= height || ((height - current) / height) < OUTPUT_SCROLL_THRESHOLD) { return true; } - } else if (current <= 0 || (current / height) < THRESHOLD) { + } else if (current <= 0 || (current / height) < OUTPUT_SCROLL_THRESHOLD) { return true; } @@ -177,7 +179,7 @@ function JobScrollService ($q, $timeout) { }; 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..e2a0735354 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -1,7 +1,8 @@ /* eslint camelcase: 0 */ -const PAGE_SIZE = 50; -const PAGE_LIMIT = 5; -const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE; +import { + OUTPUT_EVENT_LIMIT, + OUTPUT_PAGE_SIZE, +} from './constants'; /** * Check if a range overlaps another range @@ -266,7 +267,7 @@ function SlidingWindowService ($q) { return this.chain; }; - this.getNext = (displacement = PAGE_SIZE) => { + this.getNext = (displacement = OUTPUT_PAGE_SIZE) => { const [head, tail] = this.getRange(); const tailRoom = this.getMaxCounter() - tail; @@ -276,14 +277,14 @@ function SlidingWindowService ($q) { let headDisplacement = 0; - if (newTail - head > EVENT_LIMIT) { - headDisplacement = (newTail - EVENT_LIMIT) - head; + if (newTail - head > OUTPUT_EVENT_LIMIT) { + headDisplacement = (newTail - OUTPUT_EVENT_LIMIT) - head; } return this.move([head + headDisplacement, tail + tailDisplacement]); }; - this.getPrevious = (displacement = PAGE_SIZE) => { + this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => { const [head, tail] = this.getRange(); const headRoom = head - 1; @@ -293,8 +294,8 @@ function SlidingWindowService ($q) { let tailDisplacement = 0; - if (tail - newHead > EVENT_LIMIT) { - tailDisplacement = tail - (newHead + EVENT_LIMIT); + if (tail - newHead > OUTPUT_EVENT_LIMIT) { + tailDisplacement = tail - (newHead + OUTPUT_EVENT_LIMIT); } return this.move([newHead, tail - tailDisplacement]); @@ -332,12 +333,12 @@ function SlidingWindowService ($q) { this.getFirst = () => this.clear() .then(() => this.api.getFirst()) .then(events => this.pushFront(events)) - .then(() => this.moveTail(PAGE_SIZE)); + .then(() => this.moveTail(OUTPUT_PAGE_SIZE)); this.getLast = () => this.clear() .then(() => this.api.getLast()) .then(events => this.pushBack(events)) - .then(() => this.moveHead(-PAGE_SIZE)); + .then(() => this.moveHead(-OUTPUT_PAGE_SIZE)); this.getTailCounter = () => { const tail = Math.max(...Object.keys(this.records)); @@ -360,7 +361,7 @@ function SlidingWindowService ($q) { this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; this.getRecordCount = () => Object.keys(this.records).length; - this.getCapacity = () => EVENT_LIMIT - this.getRecordCount(); + 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..987232d21e 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -1,14 +1,15 @@ /* 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); @@ -62,11 +63,11 @@ function JobStatusService (moment, message) { }; 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 +93,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 +106,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,9 +194,9 @@ 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) { return; diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js index 953c886882..c243f597be 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 }) => { @@ -28,7 +30,7 @@ function OutputStream ($q) { 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(); } From fed729f1014b4912e3766c9ead6864a34ccb7604 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 30 Jul 2018 18:16:54 -0400 Subject: [PATCH 2/3] rewrite output scrolling service --- awx/ui/client/features/output/_index.less | 6 + .../features/output/api.events.service.js | 50 +++--- .../features/output/index.controller.js | 132 +++++++++++----- awx/ui/client/features/output/index.view.html | 17 +- awx/ui/client/features/output/page.service.js | 1 + .../client/features/output/scroll.service.js | 145 ++++++++---------- .../client/features/output/slide.service.js | 15 +- .../client/features/output/stream.service.js | 2 + 8 files changed, 200 insertions(+), 168 deletions(-) diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index 67224f387a..3e14f353a1 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -74,6 +74,12 @@ color: @at-blue; } + &-menuIconStack--wrapper { + &:hover { + color: @at-blue; + } + } + &-row { display: flex; diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index 8db532cc25..7b7571fa72 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -5,8 +5,8 @@ import { } from './constants'; const BASE_PARAMS = { - order_by: OUTPUT_ORDER_BY, page_size: OUTPUT_PAGE_SIZE, + order_by: OUTPUT_ORDER_BY, }; const merge = (...objs) => _.merge({}, ...objs); @@ -20,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; @@ -33,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; }); @@ -62,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; }); @@ -84,7 +85,6 @@ function JobEventsApiService ($http, $q) { return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results, count } = data; - const maxCounter = Math.max(...results.map(({ counter }) => counter)); let rotated = results; @@ -97,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; }); @@ -119,11 +116,8 @@ function JobEventsApiService ($http, $q) { 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; }); diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 8532bf4416..5c71a1b861 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -22,9 +22,6 @@ const bufferState = [0, 0]; // [length, count] const listeners = []; const rx = []; -let following = false; -let attach = true; - function bufferInit () { rx.length = 0; @@ -57,59 +54,91 @@ function bufferEmpty (min, max) { return removed; } +let attached = false; +let noframes = false; +let isOnLastPage = false; + function onFrames (events) { - if (!following) { + if (noframes) { + return $q.resolve(); + } + + if (!attached) { 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) { + if (minCounter > slide.getTailCounter() + 1) { return $q.resolve(); } - if (!attach) { - return $q.resolve(); - } + attached = true; + } - follow(); + if (vm.isInFollowMode) { + vm.isFollowing = true; } const capacity = slide.getCapacity(); + if (capacity <= 0 && !isOnLastPage) { + attached = false; + + return $q.resolve(); + } + return slide.popBack(events.length - capacity) .then(() => slide.pushFront(events)) .then(() => { - scroll.setScrollPosition(scroll.getScrollHeight()); + if (vm.isFollowing && scroll.isBeyondLowerThreshold()) { + scroll.scrollToBottom(); + } return $q.resolve(); }); } function first () { - unfollow(); scroll.pause(); + unfollow(); - return slide.getFirst() + attached = false; + noframes = true; + isOnLastPage = false; + + slide.getFirst() .then(() => { scroll.resume(); + noframes = false; return $q.resolve(); }); } function next () { + if (vm.isFollowing) { + return $q.resolve(); + } + scroll.pause(); return slide.getNext() + .then(() => { + isOnLastPage = slide.isOnLastPage(); + if (isOnLastPage) { + stream.setMissingCounterThreshold(slide.getTailCounter() + 1); + if (scroll.isBeyondLowerThreshold()) { + scroll.scrollToBottom(); + follow(); + } + } + }) .finally(() => scroll.resume()); } function previous () { - unfollow(); scroll.pause(); const initialPosition = scroll.getScrollPosition(); + isOnLastPage = false; return slide.getPrevious() .then(popHeight => { @@ -121,6 +150,22 @@ function previous () { .finally(() => scroll.resume()); } +function menuLast () { + if (vm.isFollowing) { + unfollow(); + + return $q.resolve(); + } + + if (isOnLastPage) { + scroll.scrollToBottom(); + + return $q.resolve(); + } + + return last(); +} + function last () { scroll.pause(); @@ -129,7 +174,8 @@ function last () { stream.setMissingCounterThreshold(slide.getTailCounter() + 1); scroll.setScrollPosition(scroll.getScrollHeight()); - attach = true; + isOnLastPage = true; + follow(); scroll.resume(); return $q.resolve(); @@ -141,28 +187,21 @@ function down () { } function up () { - if (following) { - unfollow(); - } else { - scroll.moveUp(); - } + scroll.moveUp(); } function follow () { - scroll.pause(); - scroll.hide(); + isOnLastPage = slide.isOnLastPage(); - following = true; - vm.isFollowing = following; + if (resource.model.get('event_processing_finished')) return; + if (!isOnLastPage) return; + + vm.isInFollowMode = true; } function unfollow () { - attach = false; - following = false; - vm.isFollowing = following; - - scroll.unhide(); - scroll.resume(); + vm.isInFollowMode = false; + vm.isFollowing = false; } function togglePanelExpand () { @@ -276,6 +315,13 @@ function reloadState (params) { return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' }); } +function getMaxCounter () { + const apiMax = resource.events.getMaxCounter(); + const wsMax = stream.getMaxCounter(); + + return Math.max(apiMax, wsMax); +} + function OutputIndexController ( _$compile_, _$q_, @@ -318,9 +364,10 @@ 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.isInFollowMode = false; vm.toggleMenuExpand = toggleMenuExpand; vm.toggleLineExpand = toggleLineExpand; vm.showHostDetails = showHostDetails; @@ -330,10 +377,21 @@ function OutputIndexController ( bufferInit(); status.init(resource); - slide.init(render, resource.events, scroll); - + slide.init(render, resource.events, scroll, { getMaxCounter }); render.init({ compile, toggles: vm.toggleLineEnabled }); - scroll.init({ previous, next }); + + scroll.init({ + next, + previous, + onLeaveLower () { + unfollow(); + return $q.resolve(); + }, + onEnterLower () { + follow(); + return $q.resolve(); + }, + }); stream.init({ bufferAdd, diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index c588f2b375..511da3c6dc 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -24,10 +24,9 @@ -
- +
@@ -36,8 +35,7 @@
- +
@@ -48,15 +46,6 @@
- -
-
-

-

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