diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index 368e2a4e65..4684bb8b16 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -104,6 +104,10 @@ user-select: none; } + &-line--clickable { + cursor: pointer; + } + &-event { .at-mixin-event(); } diff --git a/awx/ui/client/features/output/constants.js b/awx/ui/client/features/output/constants.js index a3acc997b6..4e80e1d7bb 100644 --- a/awx/ui/client/features/output/constants.js +++ b/awx/ui/client/features/output/constants.js @@ -16,6 +16,7 @@ export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPL export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container'; export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable'; export const OUTPUT_ELEMENT_LAST = '#atStdoutMenuLast'; +export const OUTPUT_MAX_BUFFER_LENGTH = 1000; export const OUTPUT_MAX_LAG = 120; export const OUTPUT_NO_COUNT_JOB_TYPES = ['ad_hoc_command', 'system_job', 'inventory_update']; export const OUTPUT_ORDER_BY = 'counter'; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 18e0357ed1..4e59735f24 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -17,61 +17,21 @@ let scroll; let status; let slide; let stream; +let page; let vm; - -const bufferState = [0, 0]; // [length, count] const listeners = []; -const rx = []; +let lockFrames = false; -function bufferInit () { - rx.length = 0; - - bufferState[0] = 0; - bufferState[1] = 0; -} - -function bufferAdd (event) { - rx.push(event); - - bufferState[0] += 1; - bufferState[1] += 1; - - return bufferState[1]; -} - -function bufferEmpty (min, max) { - let count = 0; - let removed = []; - - for (let i = bufferState[0] - 1; i >= 0; i--) { - if (rx[i].counter <= max) { - removed = removed.concat(rx.splice(i, 1)); - count++; - } - } - - bufferState[0] -= count; - - return removed; -} - -let lockFrames; function onFrames (events) { - if (lockFrames) { - events.forEach(bufferAdd); - return $q.resolve(); - } - events = slide.pushFrames(events); - const popCount = events.length - slide.getCapacity(); - const isAttached = events.length > 0; - if (!isAttached) { - stopFollowing(); + if (lockFrames) { return $q.resolve(); } + const popCount = events.length - render.getCapacity(); + if (!vm.isFollowing && canStartFollowing()) { startFollowing(); } @@ -86,13 +46,13 @@ function onFrames (events) { scroll.scrollToBottom(); } - return slide.popBack(popCount) + return render.popBack(popCount) .then(() => { if (vm.isFollowing) { scroll.scrollToBottom(); } - return slide.pushFront(events); + return render.pushFront(events); }) .then(() => { if (vm.isFollowing) { @@ -105,27 +65,44 @@ function onFrames (events) { }); } -function first () { +// +// Menu Controls (Running) +// + +function firstRange () { if (scroll.isPaused()) { return $q.resolve(); } + stopFollowing(); + lockFollow = true; + + if (slide.isOnFirstPage()) { + scroll.resetScrollPosition(); + + return $q.resolve(); + } + scroll.pause(); lockFrames = true; - stopFollowing(); + return render.clear() + .then(() => slide.getFirst()) + .then(results => render.pushFront(results)) + .then(() => slide.getNext()) + .then(results => { + const popCount = results.length - render.getCapacity(); - return slide.getFirst() - .then(() => { - scroll.resetScrollPosition(); + return render.popBack(popCount) + .then(() => render.pushFront(results)); }) .finally(() => { scroll.resume(); - lockFrames = false; + lockFollow = false; }); } -function next () { +function nextRange () { if (vm.isFollowing) { scroll.scrollToBottom(); @@ -136,34 +113,49 @@ function next () { return $q.resolve(); } - if (slide.getTailCounter() >= slide.getMaxCounter()) { - return $q.resolve(); - } - scroll.pause(); lockFrames = true; return slide.getNext() + .then(results => { + const popCount = results.length - render.getCapacity(); + + return render.popBack(popCount) + .then(() => render.pushFront(results)); + }) .finally(() => { scroll.resume(); lockFrames = false; + + return $q.resolve(); }); } -function previous () { +function previousRange () { if (scroll.isPaused()) { return $q.resolve(); } scroll.pause(); + stopFollowing(); lockFrames = true; - stopFollowing(); - - const initialPosition = scroll.getScrollPosition(); + let initialPosition; + let popHeight; return slide.getPrevious() - .then(popHeight => { + .then(results => { + const popCount = results.length - render.getCapacity(); + initialPosition = scroll.getScrollPosition(); + + return render.popFront(popCount) + .then(() => { + popHeight = scroll.getScrollHeight(); + + return render.pushBack(results); + }); + }) + .then(() => { const currentHeight = scroll.getScrollHeight(); scroll.setScrollPosition(currentHeight - popHeight + initialPosition); @@ -172,10 +164,12 @@ function previous () { .finally(() => { scroll.resume(); lockFrames = false; + + return $q.resolve(); }); } -function last () { +function lastRange () { if (scroll.isPaused()) { return $q.resolve(); } @@ -183,16 +177,39 @@ function last () { scroll.pause(); lockFrames = true; - return slide.getLast() + return render.clear() + .then(() => slide.getLast()) + .then(results => render.pushFront(results)) .then(() => { stream.setMissingCounterThreshold(slide.getTailCounter() + 1); + scroll.scrollToBottom(); + lockFrames = false; return $q.resolve(); }) .finally(() => { scroll.resume(); - lockFrames = false; + + return $q.resolve(); + }); +} + +function menuLastRange () { + if (vm.isFollowing) { + lockFollow = true; + stopFollowing(); + + return $q.resolve(); + } + + lockFollow = false; + + return lastRange() + .then(() => { + startFollowing(); + + return $q.resolve(); }); } @@ -211,8 +228,7 @@ function canStartFollowing () { if (followOnce && // one-time activation from top of first page scroll.isBeyondUpperThreshold() && - slide.getHeadCounter() === 1 && - slide.getTailCounter() >= OUTPUT_PAGE_SIZE) { + slide.getTailCounter() - slide.getHeadCounter() >= OUTPUT_PAGE_SIZE) { followOnce = false; return true; @@ -242,23 +258,159 @@ function stopFollowing () { vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); } +// +// Menu Controls (Page Mode) +// + +function firstPage () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return render.clear() + .then(() => page.getFirst()) + .then(results => render.pushFront(results)) + .then(() => page.getNext()) + .then(results => { + const popCount = page.trimHead(); + + return render.popBack(popCount) + .then(() => render.pushFront(results)); + }) + .finally(() => { + scroll.resume(); + + return $q.resolve(); + }); +} + +function lastPage () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return render.clear() + .then(() => page.getLast()) + .then(results => render.pushBack(results)) + .then(() => page.getPrevious()) + .then(results => { + const popCount = page.trimTail(); + + return render.popFront(popCount) + .then(() => render.pushBack(results)); + }) + .then(() => { + scroll.scrollToBottom(); + + return $q.resolve(); + }) + .finally(() => { + scroll.resume(); + + return $q.resolve(); + }); +} + +function nextPage () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return page.getNext() + .then(results => { + const popCount = page.trimHead(); + + return render.popBack(popCount) + .then(() => render.pushFront(results)); + }) + .finally(() => { + scroll.resume(); + }); +} + +function previousPage () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + let initialPosition; + let popHeight; + + return page.getPrevious() + .then(results => { + const popCount = page.trimTail(); + initialPosition = scroll.getScrollPosition(); + + return render.popFront(popCount) + .then(() => { + popHeight = scroll.getScrollHeight(); + + return render.pushBack(results); + }); + }) + .then(() => { + const currentHeight = scroll.getScrollHeight(); + scroll.setScrollPosition(currentHeight - popHeight + initialPosition); + + return $q.resolve(); + }) + .finally(() => { + scroll.resume(); + + return $q.resolve(); + }); +} + +// +// Menu Controls +// + +function first () { + if (vm.isProcessingFinished) { + return firstPage(); + } + + return firstRange(); +} + +function last () { + if (vm.isProcessingFinished) { + return lastPage(); + } + + return lastRange(); +} + +function next () { + if (vm.isProcessingFinished) { + return nextPage(); + } + + return nextRange(); +} + +function previous () { + if (vm.isProcessingFinished) { + return previousPage(); + } + + return previousRange(); +} + function menuLast () { - if (vm.isFollowing) { - lockFollow = true; - stopFollowing(); - - return $q.resolve(); + if (vm.isProcessingFinished) { + return lastPage(); } - lockFollow = false; - - if (slide.isOnLastPage()) { - scroll.scrollToBottom(); - - return $q.resolve(); - } - - return last(); + return menuLastRange(); } function down () { @@ -273,6 +425,10 @@ function togglePanelExpand () { vm.isPanelExpanded = !vm.isPanelExpanded; } +// +// Line Interaction +// + const iconCollapsed = 'fa-angle-right'; const iconExpanded = 'fa-angle-down'; const iconSelector = '.at-Stdout-toggle > i'; @@ -281,7 +437,7 @@ const lineCollapsed = 'hidden'; function toggleCollapseAll () { if (scroll.isPaused()) return; - const records = Object.keys(render.record).map(key => render.record[key]); + const records = Object.keys(render.records).map(key => render.records[key]); const plays = records.filter(({ name }) => name === EVENT_START_PLAY); const tasks = records.filter(({ name }) => name === EVENT_START_TASK); @@ -321,7 +477,7 @@ function toggleCollapseAll () { function toggleCollapse (uuid) { if (scroll.isPaused()) return; - const record = render.record[uuid]; + const record = render.records[uuid]; if (record.name === EVENT_START_PLAY) { togglePlayCollapse(uuid); @@ -333,7 +489,7 @@ function toggleCollapse (uuid) { } function togglePlayCollapse (uuid) { - const record = render.record[uuid]; + const record = render.records[uuid]; const descendants = record.children || []; const icon = $(`#${uuid} ${iconSelector}`); @@ -364,11 +520,11 @@ function togglePlayCollapse (uuid) { } descendants - .map(item => render.record[item]) + .map(item => render.records[item]) .filter(({ name }) => name === EVENT_START_TASK) - .forEach(rec => { render.record[rec.uuid].isCollapsed = true; }); + .forEach(rec => { render.records[rec.uuid].isCollapsed = true; }); - render.record[uuid].isCollapsed = !isCollapsed; + render.records[uuid].isCollapsed = !isCollapsed; } function toggleTaskCollapse (uuid) { @@ -387,7 +543,7 @@ function toggleTaskCollapse (uuid) { lines.addClass(lineCollapsed); } - render.record[uuid].isCollapsed = !isCollapsed; + render.records[uuid].isCollapsed = !isCollapsed; } function compile (html) { @@ -398,6 +554,60 @@ function showHostDetails (id, uuid) { $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); } +function showMissingEvents (uuid) { + const record = render.records[uuid]; + + const min = Math.min(...record.counters); + const max = Math.min(Math.max(...record.counters), min + OUTPUT_PAGE_SIZE); + + const selector = `#${uuid}`; + const clicked = $(selector); + + return resource.events.getRange([min, max]) + .then(results => { + const counters = results.map(({ counter }) => counter); + + for (let i = min; i <= max; i++) { + if (counters.indexOf(i) < 0) { + results = results.filter(({ counter }) => counter < i); + break; + } + } + + let lines = 0; + let untrusted = ''; + + for (let i = 0; i <= results.length - 1; i++) { + const { html, count } = render.transformEvent(results[i]); + + lines += count; + untrusted += html; + + const shifted = render.records[uuid].counters.shift(); + delete render.uuids[shifted]; + } + + const trusted = render.trustHtml(untrusted); + const elements = angular.element(trusted); + + return render + .requestAnimationFrame(() => { + elements.insertBefore(clicked); + + if (render.records[uuid].counters.length === 0) { + clicked.remove(); + delete render.records[uuid]; + } + }) + .then(() => render.compile(elements)) + .then(() => lines); + }); +} + +// +// Event Handling +// + let streaming; function stopListening () { streaming = null; @@ -420,7 +630,7 @@ function startListening () { function handleJobEvent (data) { streaming = streaming || resource.events - .getRange([Math.max(0, data.counter - 50), data.counter + 50]) + .getRange([Math.max(1, data.counter - 50), data.counter + 50]) .then(results => { results.push(data); @@ -440,12 +650,13 @@ function handleJobEvent (data) { results = results.filter(({ counter }) => counter > maxMissing); } - stream.setMissingCounterThreshold(max); results.forEach(item => { stream.pushJobEvent(item); status.pushJobEvent(item); }); + stream.setMissingCounterThreshold(min); + return $q.resolve(); }); @@ -467,12 +678,20 @@ function handleSummaryEvent (data) { stream.setFinalCounter(data.final_counter); } +// +// Search +// + function reloadState (params) { params.isPanelExpanded = vm.isPanelExpanded; return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' }); } +// +// Debug Mode +// + function clear () { stopListening(); render.clear(); @@ -481,9 +700,9 @@ function clear () { lockFollow = false; lockFrames = false; - bufferInit(); + stream.bufferInit(); status.init(resource); - slide.init(render, resource.events, scroll); + slide.init(resource.events, render); status.subscribe(data => { vm.status = data.status; }); startListening(); @@ -518,7 +737,8 @@ function OutputIndexController ( render = _render_; status = _status_; stream = _stream_; - slide = isProcessingFinished ? _page_ : _slide_; + slide = _slide_; + page = _page_; vm = this || {}; @@ -529,6 +749,7 @@ function OutputIndexController ( vm.resource = resource; vm.reloadState = reloadState; vm.isPanelExpanded = isPanelExpanded; + vm.isProcessingFinished = isProcessingFinished; vm.togglePanelExpand = togglePanelExpand; // Stdout Navigation @@ -538,16 +759,17 @@ function OutputIndexController ( vm.toggleCollapseAll = toggleCollapseAll; vm.toggleCollapse = toggleCollapse; vm.showHostDetails = showHostDetails; + vm.showMissingEvents = showMissingEvents; vm.toggleLineEnabled = resource.model.get('type') === 'job'; vm.followTooltip = vm.strings.get('tooltips.MENU_LAST'); vm.debug = _debug; render.requestAnimationFrame(() => { - bufferInit(); + render.init({ compile, toggles: vm.toggleLineEnabled }); status.init(resource); - slide.init(render, resource.events, scroll); - render.init({ compile, toggles: vm.toggleLineEnabled }); + page.init(resource.events); + slide.init(resource.events, render); scroll.init({ next, @@ -564,8 +786,6 @@ function OutputIndexController ( let showFollowTip = true; const rates = []; stream.init({ - bufferAdd, - bufferEmpty, onFrames, onFrameRate (rate) { rates.push(rate); @@ -638,4 +858,3 @@ OutputIndexController.$inject = [ ]; module.exports = OutputIndexController; - diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js index 786d26ad66..1502d39a63 100644 --- a/awx/ui/client/features/output/page.service.js +++ b/awx/ui/client/features/output/page.service.js @@ -2,244 +2,153 @@ 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, getMaxCounter } = api; - + this.init = ({ getPage, getFirst, getLast, getLastPageNumber }) => { this.api = { getPage, getFirst, getLast, getLastPageNumber, - getMaxCounter, }; - - this.storage = { - prepend, - append, - shift, - pop, - deleteRecord, - }; - - this.hooks = { - getScrollHeight, - }; - - this.records = {}; - this.uuids = {}; - - this.state = { - head: 0, - tail: 0, - }; - - this.chain = $q.resolve(); - }; - - this.pushFront = (results, key) => { - if (!results) { - return $q.resolve(); - } - - return this.storage.append(results) - .then(() => { - const tail = key || ++this.state.tail; - - this.records[tail] = {}; - results.forEach(({ counter, start_line, end_line, uuid }) => { - this.records[tail][counter] = { start_line, end_line }; - this.uuids[counter] = uuid; - }); - - return $q.resolve(); - }); - }; - - this.pushBack = (results, key) => { - if (!results) { - return $q.resolve(); - } - - return this.storage.prepend(results) - .then(() => { - const head = key || --this.state.head; - - this.records[head] = {}; - results.forEach(({ counter, start_line, end_line, uuid }) => { - this.records[head][counter] = { start_line, end_line }; - this.uuids[counter] = uuid; - }); - - return $q.resolve(); - }); - }; - - this.popBack = () => { - if (this.getRecordCount() === 0) { - return $q.resolve(); - } - - const pageRecord = this.records[this.state.head] || {}; - - let lines = 0; - const counters = []; - - Object.keys(pageRecord) - .forEach(counter => { - lines += pageRecord[counter].end_line - pageRecord[counter].start_line; - counters.push(counter); - }); - - return this.storage.shift(lines) - .then(() => { - counters.forEach(counter => { - this.storage.deleteRecord(this.uuids[counter]); - delete this.uuids[counter]; - }); - - delete this.records[this.state.head++]; - - return $q.resolve(); - }); - }; - - this.popFront = () => { - if (this.getRecordCount() === 0) { - return $q.resolve(); - } - - const pageRecord = this.records[this.state.tail] || {}; - - let lines = 0; - const counters = []; - - Object.keys(pageRecord) - .forEach(counter => { - lines += pageRecord[counter].end_line - pageRecord[counter].start_line; - counters.push(counter); - }); - - return this.storage.pop(lines) - .then(() => { - counters.forEach(counter => { - this.storage.deleteRecord(this.uuids[counter]); - delete this.uuids[counter]; - }); - - delete this.records[this.state.tail--]; - - return $q.resolve(); - }); + this.pages = {}; + this.state = { head: 0, tail: 0 }; }; this.getNext = () => { const lastPageNumber = this.api.getLastPageNumber(); const number = Math.min(this.state.tail + 1, lastPageNumber); - const isLoaded = (number >= this.state.head && number <= this.state.tail); - const isValid = (number >= 1 && number <= lastPageNumber); - - let popHeight = this.hooks.getScrollHeight(); - - if (!isValid || isLoaded) { - this.chain = this.chain - .then(() => $q.resolve(popHeight)); - - return this.chain; + if (number < 1) { + return $q.resolve([]); } - const pageCount = this.state.head - this.state.tail; - - if (pageCount >= OUTPUT_PAGE_LIMIT) { - this.chain = this.chain - .then(() => this.popBack()) - .then(() => { - popHeight = this.hooks.getScrollHeight(); - - return $q.resolve(); - }); + if (number > lastPageNumber) { + return $q.resolve([]); } - this.chain = this.chain - .then(() => this.api.getPage(number)) - .then(events => this.pushFront(events)) - .then(() => $q.resolve(popHeight)); + let promise; - return this.chain; + if (this.pages[number]) { + promise = $q.resolve(this.pages[number]); + } else { + promise = this.api.getPage(number); + } + + return promise + .then(results => { + if (results.length <= 0) { + return $q.resolve([]); + } + + this.state.tail = number; + this.pages[number] = results; + + return $q.resolve(results); + }); }; this.getPrevious = () => { const number = Math.max(this.state.head - 1, 1); - const isLoaded = (number >= this.state.head && number <= this.state.tail); - const isValid = (number >= 1 && number <= this.api.getLastPageNumber()); - - let popHeight = this.hooks.getScrollHeight(); - - if (!isValid || isLoaded) { - this.chain = this.chain - .then(() => $q.resolve(popHeight)); - - return this.chain; + if (number < 1) { + return $q.resolve([]); } - const pageCount = this.state.head - this.state.tail; - - if (pageCount >= OUTPUT_PAGE_LIMIT) { - this.chain = this.chain - .then(() => this.popFront()) - .then(() => { - popHeight = this.hooks.getScrollHeight(); - - return $q.resolve(); - }); + if (number > this.api.getLastPageNumber()) { + return $q.resolve([]); } - this.chain = this.chain - .then(() => this.api.getPage(number)) - .then(events => this.pushBack(events)) - .then(() => $q.resolve(popHeight)); + let promise; - return this.chain; + if (this.pages[number]) { + promise = $q.resolve(this.pages[number]); + } else { + promise = this.api.getPage(number); + } + + return promise + .then(results => { + if (results.length <= 0) { + return $q.resolve([]); + } + + this.state.head = number; + this.pages[number] = results; + + return $q.resolve(results); + }); }; - this.clear = () => { - const count = this.getRecordCount(); + this.getLast = () => this.api.getLast() + .then(results => { + if (results.length <= 0) { + return $q.resolve([]); + } - for (let i = 0; i <= count; ++i) { - this.chain = this.chain.then(() => this.popBack()); - } + const number = this.api.getLastPageNumber(); - return this.chain; - }; + this.state.head = number; + this.state.tail = number; + this.pages[number] = results; - this.getLast = () => this.clear() - .then(() => this.api.getLast()) - .then(events => { - const lastPage = this.api.getLastPageNumber(); + return $q.resolve(results); + }); - this.state.head = lastPage; - this.state.tail = lastPage; + this.getFirst = () => this.api.getFirst() + .then(results => { + if (results.length <= 0) { + return $q.resolve([]); + } - return this.pushBack(events, lastPage); - }) - .then(() => this.getPrevious()); - - this.getFirst = () => this.clear() - .then(() => this.api.getFirst()) - .then(events => { this.state.head = 1; this.state.tail = 1; + this.pages[1] = results; - return this.pushBack(events, 1); - }) - .then(() => this.getNext()); + return $q.resolve(results); + }); - 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(); + this.trimTail = () => { + const { tail, head } = this.state; + let popCount = 0; + + for (let i = tail; i > head; i--) { + if (!this.isOverCapacity()) { + break; + } + + if (this.pages[i]) { + popCount += this.pages[i].length; + } + + delete this.pages[i]; + + this.state.tail--; + } + + return popCount; + }; + + this.trimHead = () => { + const { head, tail } = this.state; + let popCount = 0; + + for (let i = head; i < tail; i++) { + if (!this.isOverCapacity()) { + break; + } + + if (this.pages[i]) { + popCount += this.pages[i].length; + } + + delete this.pages[i]; + + this.state.head++; + } + + return popCount; + }; + + this.isOverCapacity = () => this.state.tail - this.state.head > OUTPUT_PAGE_LIMIT; } PageService.$inject = ['$q']; diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index 2c8e296a31..7f297955fc 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -6,6 +6,7 @@ import { EVENT_STATS_PLAY, EVENT_START_TASK, OUTPUT_ELEMENT_TBODY, + OUTPUT_EVENT_LIMIT, } from './constants'; const EVENT_GROUPS = [ @@ -33,105 +34,235 @@ const hasAnsi = input => re.test(input); function JobRenderService ($q, $sce, $window) { this.init = ({ compile, toggles }) => { - this.parent = null; - this.record = {}; - this.el = $(OUTPUT_ELEMENT_TBODY); this.hooks = { compile }; + this.el = $(OUTPUT_ELEMENT_TBODY); + this.parent = null; - this.createToggles = toggles; this.state = { - collapseAll: false + head: 0, + tail: 0, + collapseAll: false, + toggleMode: toggles, }; + + this.records = {}; + this.uuids = {}; }; this.setCollapseAll = value => { this.state.collapseAll = value; + Object.keys(this.records).forEach(key => { + this.records[key].isCollapsed = value; + }); }; - this.sortByLineNumber = (a, b) => { - if (a.start_line > b.start_line) { + this.sortByCounter = (a, b) => { + if (a.counter > b.counter) { return 1; } - if (a.start_line < b.start_line) { + if (a.counter < b.counter) { return -1; } return 0; }; - this.transformEventGroup = events => { + // + // Event Data Transformation / HTML Building + // + + this.appendEventGroup = events => { let lines = 0; let html = ''; - events.sort(this.sortByLineNumber); + events.sort(this.sortByCounter); - for (let i = 0; i < events.length; ++i) { - const line = this.transformEvent(events[i]); - html += line.html; - lines += line.count; + for (let i = 0; i <= events.length - 1; i++) { + const current = events[i]; + + if (this.state.tail && current.counter !== this.state.tail + 1) { + const missing = this.appendMissingEventGroup(current); + + html += missing.html; + lines += missing.count; + } + + const eventLines = this.transformEvent(current); + + html += eventLines.html; + lines += eventLines.count; } return { html, lines }; }; - this.transformEvent = event => { - if (this.record[event.uuid]) { + this.appendMissingEventGroup = event => { + const tailUUID = this.uuids[this.state.tail]; + const tailRecord = this.records[tailUUID]; + + if (!tailRecord) { return { html: '', count: 0 }; } + let uuid; + + if (tailRecord.isMissing) { + uuid = tailUUID; + } else { + uuid = `${event.counter}-${tailUUID}`; + this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true }; + } + + for (let i = this.state.tail + 1; i < event.counter; i++) { + this.records[uuid].counters.push(i); + this.uuids[i] = uuid; + } + + if (tailRecord.isMissing) { + return { html: '', count: 0 }; + } + + if (tailRecord.end === event.start_line) { + return { html: '', count: 0 }; + } + + const html = this.buildRowHTML(this.records[uuid]); + const count = 1; + + return { html, count }; + }; + + this.prependEventGroup = events => { + let lines = 0; + let html = ''; + + events.sort(this.sortByCounter); + + for (let i = events.length - 1; i >= 0; i--) { + const current = events[i]; + + if (this.state.head && current.counter !== this.state.head - 1) { + const missing = this.prependMissingEventGroup(current); + + html = missing.html + html; + lines += missing.count; + } + + const eventLines = this.transformEvent(current); + + html = eventLines.html + html; + lines += eventLines.count; + } + + return { html, lines }; + }; + + this.prependMissingEventGroup = event => { + const headUUID = this.uuids[this.state.head]; + const headRecord = this.records[headUUID]; + + if (!headRecord) { + return { html: '', count: 0 }; + } + + let uuid; + + if (headRecord.isMissing) { + uuid = headUUID; + } else { + uuid = `${headUUID}-${event.counter}`; + this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true }; + } + + for (let i = this.state.head - 1; i > event.counter; i--) { + this.records[uuid].counters.unshift(i); + this.uuids[i] = uuid; + } + + if (headRecord.isMissing) { + return { html: '', count: 0 }; + } + + if (event.end_line === headRecord.start) { + return { html: '', count: 0 }; + } + + const html = this.buildRowHTML(this.records[uuid]); + const count = 1; + + return { html, count }; + }; + + this.transformEvent = event => { if (!event || !event.stdout) { return { html: '', count: 0 }; } + if (event.uuid && this.records[event.uuid]) { + return { html: '', count: 0 }; + } + const stdout = this.sanitize(event.stdout); const lines = stdout.split('\r\n'); + const record = this.createRecord(event, lines); + let html = ''; let count = lines.length; let ln = event.start_line; - const current = this.createRecord(ln, lines, event); - - const html = lines.reduce((concat, line, i) => { + for (let i = 0; i <= lines.length - 1; i++) { ln++; + const line = lines[i]; const isLastLine = i === lines.length - 1; - let row = this.createRow(current, ln, line); + let row = this.buildRowHTML(record, ln, line); - if (current && current.isTruncated && isLastLine) { - row += this.createRow(current); + if (record && record.isTruncated && isLastLine) { + row += this.buildRowHTML(record); count++; } - return `${concat}${row}`; - }, ''); + html += row; + } return { html, count }; }; - this.isHostEvent = (event) => { - if (typeof event.host === 'number') { - return true; - } - - if (event.type === 'project_update_event' && - event.event !== 'runner_on_skipped' && - event.event_data.host) { - return true; - } - - return false; - }; - - this.createRecord = (ln, lines, event) => { - if (!event.uuid) { + this.createRecord = (event, lines) => { + if (!event.counter) { return null; } - const info = { + if (!this.state.head || event.counter < this.state.head) { + this.state.head = event.counter; + } + + if (!this.state.tail || event.counter > this.state.tail) { + this.state.tail = event.counter; + } + + if (!event.uuid) { + this.uuids[event.counter] = event.counter; + this.records[event.counter] = { counters: [event.counter], lineCount: lines.length }; + + return this.records[event.counter]; + } + + let isHost = false; + if (typeof event.host === 'number') { + isHost = true; + } else if (event.type === 'project_update_event' && + event.event !== 'runner_on_skipped' && + event.event_data.host) { + isHost = true; + } + + const record = { + isHost, id: event.id, - line: ln + 1, + line: event.start_line + 1, name: event.event, uuid: event.uuid, level: event.event_level, @@ -139,54 +270,49 @@ function JobRenderService ($q, $sce, $window) { end: event.end_line, isTruncated: (event.end_line - event.start_line) > lines.length, lineCount: lines.length, - isHost: this.isHostEvent(event), isCollapsed: this.state.collapseAll, + counters: [event.counter], }; if (event.parent_uuid) { - info.parents = this.getParentEvents(event.parent_uuid); - if (this.record[event.parent_uuid]) { - info.isCollapsed = this.record[event.parent_uuid].isCollapsed; + record.parents = this.getParentEvents(event.parent_uuid); + if (this.records[event.parent_uuid]) { + record.isCollapsed = this.records[event.parent_uuid].isCollapsed; } } - if (info.isTruncated) { - info.truncatedAt = event.start_line + lines.length; + if (record.isTruncated) { + record.truncatedAt = event.start_line + lines.length; } if (EVENT_GROUPS.includes(event.event)) { - info.isParent = true; + record.isParent = true; if (event.event_level === 1) { this.parent = event.uuid; } if (event.parent_uuid) { - if (this.record[event.parent_uuid]) { - if (this.record[event.parent_uuid].children && - !this.record[event.parent_uuid].children.includes(event.uuid)) { - this.record[event.parent_uuid].children.push(event.uuid); + if (this.records[event.parent_uuid]) { + if (this.records[event.parent_uuid].children && + !this.records[event.parent_uuid].children.includes(event.uuid)) { + this.records[event.parent_uuid].children.push(event.uuid); } else { - this.record[event.parent_uuid].children = [event.uuid]; + this.records[event.parent_uuid].children = [event.uuid]; } } } } if (TIME_EVENTS.includes(event.event)) { - info.time = this.getTimestamp(event.created); - info.line++; + record.time = this.getTimestamp(event.created); + record.line++; } - this.record[event.uuid] = info; + this.records[event.uuid] = record; + this.uuids[event.counter] = event.uuid; - return info; - }; - - this.getRecord = uuid => this.record[uuid]; - - this.deleteRecord = uuid => { - delete this.record[uuid]; + return record; }; this.getParentEvents = (uuid, list) => { @@ -194,14 +320,14 @@ function JobRenderService ($q, $sce, $window) { // always push its parent if exists list.push(uuid); // if we can get grandparent in current visible lines, we also push it - if (this.record[uuid] && this.record[uuid].parents) { - list = list.concat(this.record[uuid].parents); + if (this.records[uuid] && this.records[uuid].parents) { + list = list.concat(this.records[uuid].parents); } return list; }; - this.createRow = (current, ln, content) => { + this.buildRowHTML = (record, ln, content) => { let id = ''; let icon = ''; let timestamp = ''; @@ -209,17 +335,23 @@ function JobRenderService ($q, $sce, $window) { let tdEvent = ''; let classList = ''; + if (record.isMissing) { + return `